From 71a97b63f0860409322b7a01d6e287ea600dbfdf Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 4 Aug 2026 14:21:09 +0300 Subject: [PATCH] =?UTF-8?q?feat(tx):=20TX-=D0=BF=D1=80=D0=BE=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D0=B8=20+=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0?= =?UTF-8?q?=20Transmit=20=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=B2=D0=BA?= =?UTF-8?q?=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Профиль — именованный снимок «как я звучу», переключаемый одним кликом (идиома 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 --- MainForm.pas | 149 +++++++++++++++- RadioController.pas | 152 +++++++++++++++++ Settings.pas | 307 +++++++++++++++++++++++++++++++++ SettingsForm.pas | 403 ++++++++++++++++++++++++++++++++++++-------- 4 files changed, 940 insertions(+), 71 deletions(-) diff --git a/MainForm.pas b/MainForm.pas index de6fb79..a0fc03b 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -356,6 +356,10 @@ type TrkAtt: TFlatSlider; BtnChannels: TFlatButton; FChannelsDropDown: TFlatDropDown; + // TX-профиль: кнопка с именем активного (блок TX, ряд DUP) + список. + // ЛКМ — список, ПКМ — вкладка Transmit в настройках. + BtnTXProfile: TFlatButton; + FTXProfileDropDown: TFlatDropDown; FChannelsForm: TObject; // TChannelsForm (cast при использовании) FBeaconScopeForm: TBeaconScopeForm; // окно констелляции маяка (ПКМ по BEACON) @@ -517,6 +521,17 @@ type Shift: TShiftState; X, Y: Integer); procedure OnChannelsDropDownSelect(Sender: TObject; Idx: Integer); procedure RefreshChannelsDropDown; + // TX-профили: рендер кнопки/списка + обработчики блока TX и вкладки Transmit. + procedure RenderTXProfile; + procedure PushTXProfilesToSettings; + procedure BtnTXProfileClick(Sender: TObject); + procedure BtnTXProfileMouseDown(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); + procedure OnTXProfileDropDownSelect(Sender: TObject; Idx: Integer); + procedure OnTXProfileSelectFromSettings(Idx: Integer); + procedure OnTXProfileAddFromSettings(const AName: string); + procedure OnTXProfileRenameFromSettings(Idx: Integer; const AName: string); + procedure OnTXProfileDeleteFromSettings(Idx: Integer); procedure OnChannelStoreChanged(Sender: TObject); procedure OnChannelActiveChanged(Idx: Integer; const Name: string; Active: Boolean); procedure PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton; @@ -1245,6 +1260,9 @@ begin FController.FNetwork.OnWideband := OnWidebandCB; // Дефолты TX, Alex и XVTR (на случай если устройство ещё не выбрано). TSettingsManager.DefaultTX(FController.FTXSettings); + // Заводской набор TX-профилей до подключения (ConnectDevice перечитает свой). + DefaultTXProfiles(FController.FTXSettings, FController.FDrivePercent, + FController.FTXProfiles); TSettingsManager.DefaultAlex(FController.FAlexSettings); TSettingsManager.DefaultOC(FController.FOCSettings); TSettingsManager.DefaultXvtr(FController.FXvtrSettings); @@ -1582,7 +1600,12 @@ begin TrkDrive.Position := FController.FDrivePercent; // слайдер ← drive% UpdateDriveLabel; // % / дБ рядом с ползунком FChannelController.OnDriveChanged; // внешнее изменение снимает память канала + // Мощность — часть TX-профиля: держим подпись в настройках свежей. + if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then + TSettingsForm(FSettingsForm).SetTXProfileDrive(FController.FDrivePercent); end; + rfTXProfile: + RenderTXProfile; rfAGCTop: begin TrkAGC.Position := FController.FAGCTop; @@ -1739,6 +1762,7 @@ begin FWidebandView.SetFillSpectrum(FController.FWidebandFill); FSpecView.FillSpectrum := FController.FSpectrumFill; if BtnDUP <> nil then StyleButton(BtnDUP, FController.FDisplayDuplex); + RenderTXProfile; // список профилей этого устройства (загружен по MAC) if FController.FLoadedGlobal.DisplayFPS > 0 then ApplyFPS(FController.FLoadedGlobal.DisplayFPS); ApplyFreqMhzDigits(EnsureRange(FController.FLoadedGlobal.FreqMhzDigits, 3, 5)); @@ -2093,6 +2117,21 @@ begin StyleButton(BtnRxMute, FController.FRxMuteOnTx); UpdateRxMuteButton; // видимость/активность по DUP + // TX-профиль — свободные колонки 2..3 того же ряда (панель не растёт по + // высоте). Подпись = имя активного профиля, ЛКМ — список, ПКМ — настройки. + BtnTXProfile := MakeBtn(PanelTX, 'Default', + LeftPanelButtonLeft(LEFT_W, 4, 2), 44, + LeftPanelButtonLeft(LEFT_W, 4, 3) + + LeftPanelButtonWidth(LEFT_W, 4, 3) + - LeftPanelButtonLeft(LEFT_W, 4, 2), + BTN_SM, BtnTXProfileClick); + BtnTXProfile.OnMouseDown := BtnTXProfileMouseDown; + StyleButton(BtnTXProfile, False); + + FTXProfileDropDown := TFlatDropDown.Create(BtnTXProfile, PanelLeft, 1, BTN_SM); + FTXProfileDropDown.OnSelect := OnTXProfileDropDownSelect; + RenderTXProfile; + LblDrv := TLabel.Create(Self); LblDrv.Parent := PanelTX; LblDrv.Left := 4; LblDrv.Top := 77; LblDrv.Caption := 'DRV'; LblDrv.Font.Color := CLR_TEXTDIM; @@ -3188,6 +3227,8 @@ begin end; if BtnChannels <> nil then StyleButton(BtnChannels, False); if FChannelsDropDown <> nil then FChannelsDropDown.ApplyStyle(StyleButton, T.Panel); + if BtnTXProfile <> nil then StyleButton(BtnTXProfile, False); + if FTXProfileDropDown <> nil then FTXProfileDropDown.ApplyStyle(StyleButton, T.Panel); StyleButton(BtnNR, BtnNR.Active); StyleButton(BtnNB, BtnNB.Active); StyleButton(BtnSNB, BtnSNB.Active); @@ -5486,7 +5527,8 @@ procedure TMainForm.CloseCTCSSPopup; begin if FCTCSSDropDown <> nil then FCTCSSDropDown.ClosePopup; CloseFMStepPopup; - if FChannelsDropDown <> nil then FChannelsDropDown.ClosePopup; + if FChannelsDropDown <> nil then FChannelsDropDown.ClosePopup; + if FTXProfileDropDown <> nil then FTXProfileDropDown.ClosePopup; end; procedure TMainForm.BtnFMRptMinusClick(Sender: TObject); @@ -5592,6 +5634,7 @@ begin if FController.FChannelStore.Count = 0 then Exit; FCTCSSDropDown.ClosePopup; FStepDropDown.ClosePopup; + if FTXProfileDropDown <> nil then FTXProfileDropDown.ClosePopup; FChannelsDropDown.Toggle; end; @@ -5627,6 +5670,101 @@ begin FChannelController.Select(Idx); end; +// --------------------------------------------------------------------------- +// TX-профили (кнопка блока TX + список) +// --------------------------------------------------------------------------- + +procedure TMainForm.RenderTXProfile; +// Единая точка рендера: подпись кнопки, состав списка и — если открыт диалог +// настроек — его вкладка Transmit (профиль переписал живые TX-настройки). +var + Names: array of string; + i, Act, Cnt: Integer; +begin + if BtnTXProfile = nil then Exit; + Cnt := FController.TXProfileCount; + Act := FController.ActiveTXProfile; + if (Act >= 0) and (Act < Cnt) then + BtnTXProfile.Caption := FController.TXProfileName(Act) + else + BtnTXProfile.Caption := 'TX Profile'; + BtnTXProfile.Invalidate; + if FTXProfileDropDown <> nil then + begin + SetLength(Names, Cnt); + for i := 0 to Cnt - 1 do Names[i] := FController.TXProfileName(i); + FTXProfileDropDown.SetItems(Names); + if (Act >= 0) and (Act < Cnt) then FTXProfileDropDown.SetItemIndex(Act); + end; + if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then + begin + TSettingsForm(FSettingsForm).LoadTXSettings(FController.FTXSettings, + IfThen(FController.FNetwork.Connected, + FController.FNetwork.Device.BoardType, FPendingBoardType)); + PushTXProfilesToSettings; + end; +end; + +procedure TMainForm.PushTXProfilesToSettings; +var + Names: array of string; + i: Integer; +begin + if FSettingsForm = nil then Exit; + SetLength(Names, FController.TXProfileCount); + for i := 0 to FController.TXProfileCount - 1 do + Names[i] := FController.TXProfileName(i); + TSettingsForm(FSettingsForm).LoadTXProfiles(Names, FController.ActiveTXProfile, + FController.FDrivePercent); +end; + +procedure TMainForm.BtnTXProfileClick(Sender: TObject); +begin + if FController.TXProfileCount = 0 then Exit; + // Чужие списки гасим поимённо: общий CloseCTCSSPopup закрыл бы и наш, и + // повторный клик по кнопке никогда не сворачивал бы список. + if FCTCSSDropDown <> nil then FCTCSSDropDown.ClosePopup; + CloseFMStepPopup; + FTXProfileDropDown.Toggle; +end; + +procedure TMainForm.BtnTXProfileMouseDown(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); +begin + if Button <> mbRight then Exit; + // ПКМ — прямо в редактор профиля (вкладка Transmit), как ПКМ по кнопке фильтра. + if FTXProfileDropDown <> nil then FTXProfileDropDown.ClosePopup; + BtnSettingsClick(nil); + if FSettingsForm <> nil then TSettingsForm(FSettingsForm).ShowTransmitPage; +end; + +procedure TMainForm.OnTXProfileDropDownSelect(Sender: TObject; Idx: Integer); +begin + // Применение (WDSP + mic-байт + drive) и персист — в контроллере; + // рендер придёт назад событием rfTXProfile. + FController.SelectTXProfile(Idx); +end; + +procedure TMainForm.OnTXProfileSelectFromSettings(Idx: Integer); +begin + FController.SelectTXProfile(Idx); +end; + +procedure TMainForm.OnTXProfileAddFromSettings(const AName: string); +begin + FController.AddTXProfile(AName); +end; + +procedure TMainForm.OnTXProfileRenameFromSettings(Idx: Integer; const AName: string); +begin + FController.RenameTXProfile(Idx, AName); +end; + +procedure TMainForm.OnTXProfileDeleteFromSettings(Idx: Integer); +begin + FController.DeleteTXProfile(Idx); +end; + procedure TMainForm.OnChannelActiveChanged(Idx: Integer; const Name: string; Active: Boolean); begin // Рендер кнопки Channel по событию адаптера каналов (Active=True → подсветка+имя). @@ -7812,10 +7950,14 @@ begin end; end; end; + // Правка «звуковых» полей — это правка активного профиля: отдельной кнопки + // «сохранить» нет, профиль ведётся за живым состоянием (см. TTXProfile). + FController.StoreActiveTXProfile; // Сохраняем сразу — настройки per-device, попадают в hpsdr_settings.json if FController.FDevConnected then begin FController.FSettings.SaveTX(FController.FDevMAC, FController.FTXSettings); + FController.FSettings.SaveTXProfiles(FController.FDevMAC, FController.FTXProfiles); FController.FSettings.Save; end; end; @@ -8624,6 +8766,10 @@ begin SF.OnThemeChange := SetLightTheme; SF.OnFreqMhzDigitsChange := ApplyFreqMhzDigits; SF.OnTXChange := OnTXSettingsChange; + SF.OnTXProfileSelect := OnTXProfileSelectFromSettings; + SF.OnTXProfileAdd := OnTXProfileAddFromSettings; + SF.OnTXProfileRename := OnTXProfileRenameFromSettings; + SF.OnTXProfileDelete := OnTXProfileDeleteFromSettings; SF.OnAlexChange := OnAlexSettingsChange; SF.OnXvtrChange := OnXvtrSettingsChange; SF.OnOCChange := OnOCSettingsChange; @@ -8632,6 +8778,7 @@ begin SF.OnWebSettingsChange := ApplyWebSettings; end; SF := TSettingsForm(FSettingsForm); + PushTXProfilesToSettings; SF.LoadTXSettings(FController.FTXSettings, IfThen(FController.FNetwork.Connected, FController.FNetwork.Device.BoardType, FPendingBoardType)); SF.LoadAlexSettings(FController.FAlexSettings, diff --git a/RadioController.pas b/RadioController.pas index 356ffcf..fceae6a 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -80,6 +80,7 @@ type rfFMDeviation, rfFMCTCSS, rfFMCTCSSTone, rfFMSQ, rfFMSQLevel, rfFMStep, rfFMStepIdx, rfFMRpt, rfChannel, + rfTXProfile, // сменился активный TX-профиль rfSMeter, rfPower, rfSWR, rfSupply, rfPLL, // телеметрия rfADCOverload, // ADC overload (рендер индикатора) rfBeaconLock, // QO-100 beacon lock: статус/коррекция @@ -424,6 +425,10 @@ type // ---- Конфиги / устройство (per-MAC) ---- FTXSettings: TTXSettings; + // Именованные TX-профили («звуковая» часть FTXSettings + drive, см. + // TTXProfile). Истина для эфира — всегда живые FTXSettings/FDrivePercent; + // активный профиль ведётся за ними (StoreActiveTXProfile). + FTXProfiles: TTXProfileList; FAlexSettings: TAlexSettings; FOCSettings: TOCSettings; // OC Control (Open Collector, openHPSDR-only) // Антенные разъёмы AD936x (Pluto/LibreSDR) на диапазон/трансвертер. @@ -577,6 +582,21 @@ type procedure ApplyTXSettingsToDSP; // TX-цепь (фильтр/gain/EQ/comp/...) → WDSP function PSOutlierSigmaEff: Double; // σ outlier-фильтра calcc (авто по плате) + // ---- TX-профили -------------------------------------------------------- + // Профиль — именованный снимок «звуковой» части TX-цепи (состав в + // TTXProfile). Отдельной кнопки «сохранить» нет: правки ручками пишутся в + // активный профиль сразу (StoreActiveTXProfile), как и всё остальное в + // EWSDR. Переключение применяет снимок в эфир немедленно. + function TXProfileCount: Integer; + function TXProfileName(Idx: Integer): string; + function ActiveTXProfile: Integer; + procedure SelectTXProfile(Idx: Integer); + procedure StoreActiveTXProfile(Persist: Boolean = False); + function AddTXProfile(const AName: string): Integer; // форк текущего звука + procedure RenameTXProfile(Idx: Integer; const AName: string); + procedure DeleteTXProfile(Idx: Integer); + procedure SaveTXProfilesToConfig; + // ---- Управление диапазонами (состояние + DSP; рендер через события) ---- // MakeBandSettings — снимок текущего состояния в запись диапазона. // SaveCurrentBand — сохраняет текущее состояние в кэш+конфиг (или в XVTR-слот). @@ -4222,6 +4242,10 @@ procedure TRadioController.SetDrive(V: Integer); begin if V < 0 then V := 0 else if V > 100 then V := 100; FDrivePercent := V; + // Мощность входит в профиль: ведём её в активном без записи на диск (слайдер + // шлёт события пачками; на диск профили уходят на переключении/остановке). + if (FTXProfiles.ActiveIdx >= 0) and (FTXProfiles.ActiveIdx < FTXProfiles.Count) then + FTXProfiles.Items[FTXProfiles.ActiveIdx].DrivePercent := FDrivePercent; FDriveLevel := CalcDriveByte; ApplyWDSPDrive(FDrivePercent); ApplyPlutoTxAtten; // Pluto: drive живьём меняет TX-аттенюацию во время передачи @@ -4904,6 +4928,124 @@ begin FNetwork.SendDUCSpecific(DUCPkt); 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); +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); + 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); + 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.ApplyTXSettingsToDSP; begin if not FWDSPReady then Exit; @@ -5847,6 +5989,12 @@ begin // drive% (0..100) — раньше грузился через TrkDrive.Position; теперь напрямую, // слайдер выставит rfDevice-рендер. Калиброванный байт считаем после FCurrentBand. FDrivePercent := EnsureRange(FLoadedGlobal.DriveLevel, 0, 100); + // TX-профили. Секции нет (конфиг до этой версии) — строим заводской набор из + // ТЕКУЩИХ настроек: «Default» повторяет их один в один, звук после + // обновления не меняется. Активный профиль на старте не применяем — живая + // секция "tx" уже хранит последнее состояние, оно и есть истина. + if not FSettings.LoadTXProfiles(FDevMAC, FTXProfiles) then + DefaultTXProfiles(FTXSettings, FDrivePercent, FTXProfiles); // Клампим LastBand — иначе битый/старый конфиг → RestoreBand выйдет по гарду, // а последующие SetMode/SetFilterIdx запишут в FBandCache[невалид] за массив. FCurrentBand := EnsureRange(FLoadedGlobal.LastBand, 0, CFG_BAND_COUNT - 1); @@ -5999,6 +6147,10 @@ begin FSettings.SaveAnt936x(FDevMAC, FAnt936x); FSettings.SaveOC(FDevMAC, FOCSettings); FSettings.SaveFilters(FDevMAC, FFilterTable); + // Активный TX-профиль дописываем живыми значениями (drive/громкость микро- + // фона ездят мимо диалога настроек) и пишем список целиком. + StoreActiveTXProfile; + FSettings.SaveTXProfiles(FDevMAC, FTXProfiles); // XVTR LastFreq → актуальная видимая частота в момент остановки. if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) then FXvtrSettings.Entries[FCurrentXvtr].LastFreq := FVfoA; diff --git a/Settings.pas b/Settings.pas index ae1f861..7bb30da 100644 --- a/Settings.pas +++ b/Settings.pas @@ -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 diff --git a/SettingsForm.pas b/SettingsForm.pas index c5308c4..31bf755 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -31,6 +31,13 @@ uses FlatRadioButton, EqualizerControl, AudioOutput, AudioInput, AppTheme, Settings, BoardUtils, DpiUtils; +const + // Подвкладки страницы Transmit: Profile / EQ / Hardware / Display. + TXTAB_COUNT = 4; + // Tag контейнера подвкладки: ApplyTheme красит такие панели фоном страницы, + // а не цветом группы (иначе подложка выглядела бы как одна гигантская группа). + TAG_PAGE_BG = 909; + type // PA: MaxPower в Вт, BandCal[0..10] — калибровка 38.8..100.0 на диапазон TOnPASettingsChange = procedure(MaxPower: Double; @@ -45,6 +52,10 @@ type TOnCalibrationChange = procedure(const C: TCalibration) of object; // TX-настройки целиком (любая правка → один callback с актуальной структурой) TOnTXSettingsChange = procedure(const T: TTXSettings) of object; + // TX-профили: выбор/переименование/удаление по индексу, создание — по имени. + TOnTXProfileIdx = procedure(Idx: Integer) of object; + TOnTXProfileName = procedure(const AName: string) of object; + TOnTXProfileIdxName = procedure(Idx: Integer; const AName: string) of object; TOnFreqMhzDigitsChange = procedure(Digits: Integer) of object; TOnDisplayParamChange = procedure(FFTSize, WindowType, SpecDetector, @@ -204,6 +215,18 @@ type // ---- Transmit tab controls ---- // Текущее состояние, отдаётся в OnTXChange целиком при любой правке. FTX: TTXSettings; + // Подвкладки страницы Transmit: Profile / EQ / Hardware / Display. + // Первые две — то, что хранится в TX-профиле, вторые две — свойства + // устройства и экрана (от профиля не зависят). См. TTXProfile в Settings. + FTXSubPage: array[0..TXTAB_COUNT-1] of TPanel; + FTXSubBtn: array[0..TXTAB_COUNT-1] of TFlatButton; + FTXSubIdx: Integer; + // Полоса профиля наверху страницы + FCmbTXProfile: TFlatComboBox; + FBtnTXProfNew: TFlatButton; + FBtnTXProfRen: TFlatButton; + FBtnTXProfDel: TFlatButton; + FLblTXProfDrive: TLabel; // «Drive профиля: N %» (ползунок — в главном окне) FCmbTXMicJack: TFlatComboBox; // "Mic In" / "Line In" FChkTXMicBoost: TFlatCheckBox; // +20 dB (только Mic In) FLblLineInGain: TLabel; // метка "Line In gain" (только Line In) @@ -411,6 +434,10 @@ type FOnWfAGCNFChange: TOnWfAGCNFChange; FOnFreqMhzDigitsChange: TOnFreqMhzDigitsChange; FOnTXChange: TOnTXSettingsChange; + FOnTXProfileSelect: TOnTXProfileIdx; + FOnTXProfileAdd: TOnTXProfileName; + FOnTXProfileRename: TOnTXProfileIdxName; + FOnTXProfileDelete: TOnTXProfileIdx; FLoading: Boolean; @@ -462,6 +489,13 @@ type procedure OnTXAnyChange(Sender: TObject); procedure OnMicJackChange(Sender: TObject); procedure UpdateMicJackVisibility; + // Подвкладки и полоса профиля на странице Transmit + procedure SelectTXSubPage(Idx: Integer); + procedure TXSubTabClick(Sender: TObject); + procedure OnTXProfileComboChange(Sender: TObject); + procedure BtnTXProfNewClick(Sender: TObject); + procedure BtnTXProfRenClick(Sender: TObject); + procedure BtnTXProfDelClick(Sender: TObject); procedure ApplyTheme(const T: TAppTheme); procedure NavClick(Sender: TObject); procedure SelectPage(APage: TWinControl; ANav: TFlatButton); @@ -571,6 +605,15 @@ type const SerAndromeda: array of Boolean; TcpEnabled: Boolean; TcpPort: Integer); procedure LoadTXSettings(const T: TTXSettings; BoardType: Integer = 0); + // Список TX-профилей + активный + drive активного (ползунок DRV живёт в + // главном окне, здесь показываем его значение как часть профиля). + procedure LoadTXProfiles(const Names: array of string; + ActiveIdx, DrivePct: Integer); + // Живое обновление подписи «Drive профиля» (ползунок DRV в главном окне). + procedure SetTXProfileDrive(DrivePct: Integer); + // Открыть диалог сразу на странице Transmit (ПКМ по кнопке профиля). + // SubIdx: 0=Profile, 1=EQ, 2=Hardware, 3=Display. + procedure ShowTransmitPage(SubIdx: Integer = 0); procedure LoadAlexSettings(const A: TAlexSettings; BoardType: Integer); procedure LoadXvtrSettings(const X: TXvtrSettings); procedure LoadOCSettings(const O: TOCSettings); @@ -608,6 +651,10 @@ type property OnThemeChange: TOnThemeChange read FOnThemeChange write FOnThemeChange; property OnFreqMhzDigitsChange: TOnFreqMhzDigitsChange read FOnFreqMhzDigitsChange write FOnFreqMhzDigitsChange; property OnTXChange: TOnTXSettingsChange read FOnTXChange write FOnTXChange; + property OnTXProfileSelect: TOnTXProfileIdx read FOnTXProfileSelect write FOnTXProfileSelect; + property OnTXProfileAdd: TOnTXProfileName read FOnTXProfileAdd write FOnTXProfileAdd; + property OnTXProfileRename: TOnTXProfileIdxName read FOnTXProfileRename write FOnTXProfileRename; + property OnTXProfileDelete: TOnTXProfileIdx read FOnTXProfileDelete write FOnTXProfileDelete; property OnWfAGCNFChange: TOnWfAGCNFChange read FOnWfAGCNFChange write FOnWfAGCNFChange; property OnADCChange: TOnADCChange read FOnADCChange write FOnADCChange; property OnWebSettingsChange: TOnWebSettingsChange read FOnWebSettingsChange write FOnWebSettingsChange; @@ -1352,6 +1399,40 @@ const var Grp: TPanel; Y, i: Integer; + SubTop: Integer; // общий (немасштабированный) Top всех четырёх подвкладок + + // Подложка подвкладки: страница Transmit — четыре независимых экрана, + // переключаемых сегментами (свиток на 2000 px не листают, в нём ищут). + function SubPage(Idx: Integer; ATop: Integer): TPanel; + begin + Result := TPanel.Create(Self); + Result.Parent := FPageTransmit; + Result.BevelOuter := bvNone; + Result.Color := CLR_BG; + Result.Tag := TAG_PAGE_BG; // ApplyTheme: фон страницы, не группа + Result.SetBounds(0, DpiScale(ATop), + DpiScale(GRP_W + 2 * MARGIN), DpiScale(400)); + Result.Visible := False; + FTXSubPage[Idx] := Result; + end; + + function MkBtn(AParent: TWinControl; AX, AY, AW: Integer; + const ACap: string; AClick: TNotifyEvent): TFlatButton; + begin + Result := TFlatButton.Create(Self); + Result.Parent := AParent; + Result.Caption := ACap; + Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(AW), DpiScale(BTN_H)); + Result.ClrNorm := CLR_PANEL; + Result.ClrBorder := CLR_BORDER; + Result.ClrHot := TColor($00242424); + Result.ClrActive := TColor($00204030); + Result.ClrText := CLR_TEXT; + Result.ClrTextAct := CLR_ACCENT; + Result.Font.Name := UI_FONT; + Result.Font.Size := 9; + Result.OnClick := AClick; + end; function MkSpin(AParent: TWinControl; AX, AY: Integer; AMin, AMax, AVal: Integer): TFlatSpinEdit; begin @@ -1417,17 +1498,46 @@ begin with TLabel.Create(Self) do begin Parent := FPageTransmit; - Caption := 'TX-цепь WDSP: микрофон, фильтр, динамическая обработка, модуляция.'; - SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(700), DpiScale(20)); + Caption := 'Profile и EQ — содержимое активного TX-профиля; ' + + 'Hardware и Display — свойства устройства, от профиля не зависят.'; + SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(GRP_W), DpiScale(20)); Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; end; - // ---- Mic options ---- - // Mic source (Radio vs SoundCard) определяется автоматически по источнику PTT. - Y := 86; - Grp := MakeGroupPanel(FPageTransmit, 'Microphone', MARGIN, Y, GRP_W, 188); + // ---- Полоса профиля (над подвкладками, видна всегда) -------------------- + Grp := MakeGroupPanel(FPageTransmit, 'TX Profile', MARGIN, 78, GRP_W, 124); + + MakeLbl(Grp, 'Active', PAD, R1 + 5, 60); + FCmbTXProfile := MakeCombo(Grp, PAD + 60, R1, 230, OnTXProfileComboChange); + 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); + + MakeLbl(Grp, 'Правки на вкладках Profile и EQ пишутся в активный профиль сразу — ' + + 'отдельной кнопки «сохранить» нет.', PAD, R1 + 30, GRP_W - 2 * PAD); + MakeLbl(Grp, 'New... создаёт копию текущего звука под новым именем.', + PAD, R1 + 48, GRP_W - 2 * PAD); + + // ---- Сегменты подвкладок ------------------------------------------------ + Y := 78 + 124 + 12; + FTXSubBtn[0] := MkBtn(FPageTransmit, MARGIN, Y, 132, 'Profile', TXSubTabClick); + FTXSubBtn[1] := MkBtn(FPageTransmit, MARGIN + 136, Y, 132, 'EQ', TXSubTabClick); + FTXSubBtn[2] := MkBtn(FPageTransmit, MARGIN + 272, Y, 132, 'Hardware', TXSubTabClick); + FTXSubBtn[3] := MkBtn(FPageTransmit, MARGIN + 408, Y, 132, 'Display', TXSubTabClick); + for i := 0 to TXTAB_COUNT - 1 do FTXSubBtn[i].Tag := i; + + Inc(Y, BTN_H + 14); + SubTop := Y; // ★ немасштабированный: FTXSubPage[].Top уже в пикселях + + // ========================================================================= + // Подвкладка 0 — Profile: всё, что определяет звук в эфире + // ========================================================================= + SubPage(0, SubTop); + + // ---- Микрофонный вход (часть профиля: гарнитура vs микшер-ESSB) ---- + Grp := MakeGroupPanel(FTXSubPage[0], 'Microphone', MARGIN, 0, GRP_W, 176); // Row 0: Mic jack selector | Boost (Mic In) или LineInGain (Line In) | Bias | PTT MakeLbl(Grp, 'Mic jack', PAD, R1 + 5, 65); @@ -1444,58 +1554,35 @@ begin FChkTXMicBias := MkChk(Grp, PAD + 375, R1, 105, 'Mic bias'); FChkTXMicPTT := MkChk(Grp, PAD + 490, R1, 160, 'Mic-jack PTT'); - // Row 1: Connector Tip/Ring (только Orion boards 4 и 5) - FLblTipRing := MakeLbl(Grp, 'Connector', PAD, R1 + STEP + 5, 70); - FCmbTXMicTR := MkCmb(Grp, PAD + 73, R1 + STEP, 90); + // Row 1: Mic gain + MakeLbl(Grp, 'Mic gain (dB)', PAD, R1 + STEP + 5, LW); + FEdTXMicGain := MkFloat(Grp, CX, R1 + STEP, 10.0, -20.0, 60.0, 0.5); + + // Row 2: Connector Tip/Ring (только Orion boards 4 и 5). Стоит последним — + // на не-Orion строка скрыта и не оставляет дыру посреди группы. + FLblTipRing := MakeLbl(Grp, 'Connector', PAD, R1 + 2 * STEP + 5, 70); + FCmbTXMicTR := MkCmb(Grp, PAD + 73, R1 + 2 * STEP, 90); FCmbTXMicTR.Items.Add('Tip'); // MicTipRing=False, bit3=0 FCmbTXMicTR.Items.Add('Ring'); // MicTipRing=True, bit3=1 FCmbTXMicTR.ItemIndex := 0; FLblTipRing.Visible := False; FCmbTXMicTR.Visible := False; - // Row 2: Mic gain | ATT on TX - MakeLbl(Grp, 'Mic gain (dB)', PAD, R1 + 2 * STEP + 5, LW); - FEdTXMicGain := MkFloat(Grp, CX, R1 + 2 * STEP, 10.0, -20.0, 60.0, 0.5); - MakeLbl(Grp, 'ATT on TX (dB)', PAD + 320, R1 + 2 * STEP + 5, 110); - FEdTXAttOnTX := MkSpin(Grp, PAD + 320 + 120, R1 + 2 * STEP, 0, 31, 31); - - // ---- Tune (TUN button) ---- - Inc(Y, 188 + 12); - Grp := MakeGroupPanel(FPageTransmit, 'Tune (TUN)', MARGIN, Y, GRP_W, 100); - - MakeLbl(Grp, 'Tune level (%)', PAD, R1 + 5, LW); - FEdTUNLevel := MkSpin(Grp, CX, R1, 0, 100, 10); - MakeLbl(Grp, 'Tone freq (Hz)', PAD + 320, R1 + 5, 110); - FEdTUNFreq := MkSpin(Grp, PAD + 320 + 120, R1, 100, 5000, 750); - - // ---- TX bandpass filter ---- - Inc(Y, 100 + 12); - Grp := MakeGroupPanel(FPageTransmit, 'TX Filter (bandpass)', MARGIN, Y, GRP_W, 220); + // ---- Кромки TX-фильтра (FIR/фаза/окно — на вкладке Hardware) ---- + Y := 176 + 12; + Grp := MakeGroupPanel(FTXSubPage[0], 'TX Filter (bandpass)', MARGIN, Y, GRP_W, 108); MakeLbl(Grp, 'Low cut (Hz)', PAD, R1 + 5, LW); FEdTXFiltLow := MkSpin(Grp, CX, R1, 0, 8000, 200); MakeLbl(Grp, 'High cut (Hz)', PAD + 320, R1 + 5, 90); FEdTXFiltHigh := MkSpin(Grp, PAD + 320 + 100, R1, 100, 12000, 3100); + MakeLbl(Grp, 'Ширина полосы голоса. ESSB — просто High cut 3500..5000; ' + + 'знак боковой ставит движок, здесь всегда магнитуда.', + PAD, R1 + 32, GRP_W - 2 * PAD); - MakeLbl(Grp, 'FIR length', PAD, R1 + STEP + 5, LW); - FCmbTXFiltNC := MkCmb(Grp, CX, R1 + STEP, 140); - FCmbTXFiltNC.Items.AddStrings(['256','512','1024','2048','4096']); - FCmbTXFiltNC.ItemIndex := 2; - - MakeLbl(Grp, 'Phase mode', PAD, R1 + 2 * STEP + 5, LW); - FCmbTXFiltMP := MkCmb(Grp, CX, R1 + 2 * STEP, 200); - FCmbTXFiltMP.Items.AddStrings(['Linear Phase','Low Latency']); - FCmbTXFiltMP.ItemIndex := 1; - - MakeLbl(Grp, 'Window', PAD, R1 + 3 * STEP + 5, LW); - FCmbTXFiltWindow := MkCmb(Grp, CX, R1 + 3 * STEP, 240); - for i := 0 to High(WINDOW_NAMES) do - FCmbTXFiltWindow.Items.Add(WINDOW_NAMES[i]); - FCmbTXFiltWindow.ItemIndex := 1; - - // ---- Compressor / Leveler / ALC / Phase Rotator ---- - Inc(Y, 220 + 12); - Grp := MakeGroupPanel(FPageTransmit, 'Dynamic processing', MARGIN, Y, GRP_W, 220); + // ---- Динамическая обработка ---- + Inc(Y, 108 + 12); + Grp := MakeGroupPanel(FTXSubPage[0], 'Dynamic processing', MARGIN, Y, GRP_W, 220); FChkTXCompOn := MkChk(Grp, PAD, R1, 220, 'Compressor'); MakeLbl(Grp, 'Gain (dB)', PAD + 230, R1 + 5, 80); @@ -1519,9 +1606,28 @@ begin MakeLbl(Grp, 'Corner (Hz)', PAD + 430, R1 + 3 * STEP + 5, 80); FEdTXPHRFreq := MkFloat(Grp, PAD + 520, R1 + 3 * STEP, 338.0, 50.0, 3000.0, 1.0); - // ---- TX EQ ---- + // ---- Мощность и несущая AM (тоже часть профиля) ---- Inc(Y, 220 + 12); - Grp := MakeGroupPanel(FPageTransmit, 'TX Equalizer', MARGIN, Y, GRP_W, 390); + Grp := MakeGroupPanel(FTXSubPage[0], 'Power / AM carrier', MARGIN, Y, GRP_W, 148); + + MakeLbl(Grp, 'Tune level (%)', PAD, R1 + 5, LW); + FEdTUNLevel := MkSpin(Grp, CX, R1, 0, 100, 10); + MakeLbl(Grp, 'AM carrier level', PAD + 320, R1 + 5, 110); + FEdTXAMCarrier := MkFloat(Grp, PAD + 320 + 120, R1, 0.5, 0.0, 1.0, 0.05); + + FLblTXProfDrive := MakeLbl(Grp, 'Drive профиля: —', PAD, R1 + STEP + 5, 300); + MakeLbl(Grp, 'Мощность — часть профиля, но крутится ползунком DRV в главном окне: ' + + 'текущее значение уходит в активный профиль само.', + PAD, R1 + STEP + 28, GRP_W - 2 * PAD); + + FTXSubPage[0].Height := DpiScale(Y + 148 + 12); + + // ========================================================================= + // Подвкладка 1 — EQ (тоже профильный, но занимает целый экран) + // ========================================================================= + SubPage(1, SubTop); + + Grp := MakeGroupPanel(FTXSubPage[1], 'TX Equalizer', MARGIN, 0, GRP_W, 390); FTXEQControl := TEqualizerControl.Create(Self); FTXEQControl.Parent := Grp; @@ -1532,33 +1638,75 @@ begin TColor($00383838), CLR_TEXT, CLR_TEXTDIM, CLR_ACCENT, TColor($0055DDEE)); FTXEQControl.OnChange := OnTXAnyChange; - // ---- AM / FM / CTCSS ---- - Inc(Y, 390 + 12); - Grp := MakeGroupPanel(FPageTransmit, 'AM / FM / CTCSS', MARGIN, Y, GRP_W, 260); + FTXSubPage[1].Height := DpiScale(390 + 12); - MakeLbl(Grp, 'AM carrier level', PAD, R1 + 5, LW); - FEdTXAMCarrier := MkFloat(Grp, CX, R1, 0.5, 0.0, 1.0, 0.05); + // ========================================================================= + // Подвкладка 2 — Hardware: свойства устройства, профилем не переключаются + // ========================================================================= + SubPage(2, SubTop); - MakeLbl(Grp, 'FM deviation (Hz)', PAD, R1 + STEP + 5, LW); - FEdTXFMDev := MkFloat(Grp, CX, R1 + STEP, 5000.0, 1000.0, 25000.0, 100.0); + Grp := MakeGroupPanel(FTXSubPage[2], 'Radio', MARGIN, 0, GRP_W, 108); - MakeLbl(Grp, 'FM AF low cut', PAD, R1 + 2 * STEP + 5, LW); - FEdTXFMLow := MkSpin(Grp, CX, R1 + 2 * STEP, 0, 5000, 300); - MakeLbl(Grp, 'FM AF high cut', PAD + 320, R1 + 2 * STEP + 5, 100); - FEdTXFMHigh := MkSpin(Grp, PAD + 320 + 110, R1 + 2 * STEP, 100, 8000, 3000); + MakeLbl(Grp, 'ATT on TX (dB)', PAD, R1 + 5, LW); + FEdTXAttOnTX := MkSpin(Grp, CX, R1, 0, 31, 31); + MakeLbl(Grp, 'Tune tone (Hz)', PAD + 320, R1 + 5, 110); + FEdTUNFreq := MkSpin(Grp, PAD + 320 + 120, R1, 100, 5000, 750); + MakeLbl(Grp, 'ATT on TX — ослабление RX-ADC на передаче (защита входа), ' + + 'не путать с мощностью.', PAD, R1 + 32, GRP_W - 2 * PAD); - MakeLbl(Grp, 'FM emphasis', PAD, R1 + 3 * STEP + 5, LW); - FCmbTXFMEmph := MkCmb(Grp, CX, R1 + 3 * STEP, 180); + Y := 108 + 12; + Grp := MakeGroupPanel(FTXSubPage[2], 'TX Filter (DSP)', MARGIN, Y, GRP_W, 190); + + MakeLbl(Grp, 'FIR length', PAD, R1 + 5, LW); + FCmbTXFiltNC := MkCmb(Grp, CX, R1, 140); + FCmbTXFiltNC.Items.AddStrings(['256','512','1024','2048','4096']); + FCmbTXFiltNC.ItemIndex := 2; + + MakeLbl(Grp, 'Phase mode', PAD, R1 + STEP + 5, LW); + FCmbTXFiltMP := MkCmb(Grp, CX, R1 + STEP, 200); + FCmbTXFiltMP.Items.AddStrings(['Linear Phase','Low Latency']); + FCmbTXFiltMP.ItemIndex := 1; + + MakeLbl(Grp, 'Window', PAD, R1 + 2 * STEP + 5, LW); + FCmbTXFiltWindow := MkCmb(Grp, CX, R1 + 2 * STEP, 240); + for i := 0 to High(WINDOW_NAMES) do + FCmbTXFiltWindow.Items.Add(WINDOW_NAMES[i]); + FCmbTXFiltWindow.ItemIndex := 1; + + MakeLbl(Grp, 'Крутизна и задержка тракта, а не тембр — поэтому вне профиля.', + PAD, R1 + 3 * STEP + 5, GRP_W - 2 * PAD); + + Inc(Y, 190 + 12); + Grp := MakeGroupPanel(FTXSubPage[2], 'FM / CTCSS', MARGIN, Y, GRP_W, 250); + + MakeLbl(Grp, 'FM deviation (Hz)', PAD, R1 + 5, LW); + FEdTXFMDev := MkFloat(Grp, CX, R1, 5000.0, 1000.0, 25000.0, 100.0); + + MakeLbl(Grp, 'FM AF low cut', PAD, R1 + STEP + 5, LW); + FEdTXFMLow := MkSpin(Grp, CX, R1 + STEP, 0, 5000, 300); + MakeLbl(Grp, 'FM AF high cut', PAD + 320, R1 + STEP + 5, 100); + FEdTXFMHigh := MkSpin(Grp, PAD + 320 + 110, R1 + STEP, 100, 8000, 3000); + + MakeLbl(Grp, 'FM emphasis', PAD, R1 + 2 * STEP + 5, LW); + FCmbTXFMEmph := MkCmb(Grp, CX, R1 + 2 * STEP, 180); FCmbTXFMEmph.Items.AddStrings(['Pre-EQ','Post-EQ']); FCmbTXFMEmph.ItemIndex := 1; - FChkTXCTCSSOn := MkChk(Grp, PAD, R1 + 4 * STEP, 220, 'CTCSS tone'); - MakeLbl(Grp, 'Freq (Hz)', PAD + 230, R1 + 4 * STEP + 5, 80); - FEdTXCTCSSFreq := MkFloat(Grp, PAD + 320, R1 + 4 * STEP, 100.0, 50.0, 300.0, 0.1); + FChkTXCTCSSOn := MkChk(Grp, PAD, R1 + 3 * STEP, 220, 'CTCSS tone'); + MakeLbl(Grp, 'Freq (Hz)', PAD + 230, R1 + 3 * STEP + 5, 80); + FEdTXCTCSSFreq := MkFloat(Grp, PAD + 320, R1 + 3 * STEP, 100.0, 50.0, 300.0, 0.1); - // ---- TX Display (отдельный analyzer для TX-спектра/водопада) ---- - Inc(Y, 260 + 12); - Grp := MakeGroupPanel(FPageTransmit, 'TX Display (Analyzer)', MARGIN, Y, GRP_W, 318); + MakeLbl(Grp, 'Свойство канала/репитера — профилем голоса не переключается.', + PAD, R1 + 4 * STEP + 5, GRP_W - 2 * PAD); + + FTXSubPage[2].Height := DpiScale(Y + 250 + 12); + + // ========================================================================= + // Подвкладка 3 — Display: анализатор и сетка TX-спектра + // ========================================================================= + SubPage(3, SubTop); + + Grp := MakeGroupPanel(FTXSubPage[3], 'TX Display (Analyzer)', MARGIN, 0, GRP_W, 318); MakeLbl(Grp, 'FFT size', PAD, R1 + 5, LW); FCmbTXFFT := MkCmb(Grp, CX, R1, 140); @@ -1595,8 +1743,8 @@ begin FEdTXWfAvgTime := MkFloat(Grp, PAD + 540, R1 + 5 * STEP, 120.0, 1.0, 5000.0, 1.0); // ---- TX Grid (RefLevel / Range / GridStep — отдельные от RX) ---- - Inc(Y, 318 + 12); - Grp := MakeGroupPanel(FPageTransmit, 'TX Grid', MARGIN, Y, GRP_W, 170); + Y := 318 + 12; + Grp := MakeGroupPanel(FTXSubPage[3], 'TX Grid', MARGIN, Y, GRP_W, 170); MakeLbl(Grp, 'Reference level', PAD, R1 + 5, LW); FEdTXRefLevel := TFlatEdit.Create(Self); @@ -1624,8 +1772,12 @@ begin FCmbTXGridStep := MkCmb(Grp, CX, R1 + 2 * STEP, 130); for i := 0 to High(GRID_STEP_NAMES) do FCmbTXGridStep.Items.Add(GRID_STEP_NAMES[i]); FCmbTXGridStep.ItemIndex := 1; -end; + FTXSubPage[3].Height := DpiScale(Y + 170 + 12); + + FTXSubIdx := -1; + SelectTXSubPage(0); +end; procedure TSettingsForm.CollectTXFromUI; var i: Integer; begin @@ -1729,6 +1881,115 @@ begin FireTXChange; end; +// --------------------------------------------------------------------------- +// Transmit: подвкладки и полоса TX-профиля +// --------------------------------------------------------------------------- + +procedure TSettingsForm.SelectTXSubPage(Idx: Integer); +var i: Integer; +begin + if (Idx < 0) or (Idx >= TXTAB_COUNT) then Exit; + FTXSubIdx := Idx; + for i := 0 to TXTAB_COUNT - 1 do + begin + if FTXSubPage[i] <> nil then FTXSubPage[i].Visible := (i = Idx); + if FTXSubBtn[i] <> nil then FTXSubBtn[i].Active := (i = Idx); + end; +end; + +procedure TSettingsForm.TXSubTabClick(Sender: TObject); +begin + if Sender is TFlatButton then SelectTXSubPage(TFlatButton(Sender).Tag); +end; + +procedure TSettingsForm.OnTXProfileComboChange(Sender: TObject); +begin + if FLoading then Exit; + if (FCmbTXProfile = nil) or (FCmbTXProfile.ItemIndex < 0) then Exit; + // Применение снимка в эфир — снаружи (контроллер); назад придёт LoadTXSettings + // с уже новыми значениями, форма перерисуется целиком. + if Assigned(FOnTXProfileSelect) then FOnTXProfileSelect(FCmbTXProfile.ItemIndex); +end; + +procedure TSettingsForm.BtnTXProfNewClick(Sender: TObject); +var S: string; +begin + if FCmbTXProfile = nil then Exit; + if FCmbTXProfile.Items.Count >= TXPROF_MAX then + begin + MessageDlg('TX Profile', + Format('Больше %d профилей на устройство не поддерживается.', [TXPROF_MAX]), + mtInformation, [mbOK], 0); + Exit; + end; + S := 'Profile ' + IntToStr(FCmbTXProfile.Items.Count + 1); + if not InputQuery('TX Profile', 'Имя нового профиля (копия текущего звука):', S) then Exit; + if Trim(S) = '' then Exit; + if Assigned(FOnTXProfileAdd) then FOnTXProfileAdd(Trim(S)); +end; + +procedure TSettingsForm.BtnTXProfRenClick(Sender: TObject); +var S: string; Idx: Integer; +begin + if FCmbTXProfile = nil then Exit; + Idx := FCmbTXProfile.ItemIndex; + if (Idx < 0) or (Idx >= FCmbTXProfile.Items.Count) then Exit; + S := FCmbTXProfile.Items[Idx]; + if not InputQuery('TX Profile', 'Новое имя профиля:', S) then Exit; + if Trim(S) = '' then Exit; + if Assigned(FOnTXProfileRename) then FOnTXProfileRename(Idx, Trim(S)); +end; + +procedure TSettingsForm.BtnTXProfDelClick(Sender: TObject); +var Idx: Integer; +begin + if FCmbTXProfile = nil then Exit; + Idx := FCmbTXProfile.ItemIndex; + // Последний профиль не удаляем — активному профилю всегда есть куда писать. + if (Idx < 0) or (FCmbTXProfile.Items.Count <= 1) then Exit; + if MessageDlg('TX Profile', + 'Удалить профиль «' + FCmbTXProfile.Items[Idx] + '»?', + mtConfirmation, [mbYes, mbNo], 0) <> mrYes then Exit; + if Assigned(FOnTXProfileDelete) then FOnTXProfileDelete(Idx); +end; + +procedure TSettingsForm.LoadTXProfiles(const Names: array of string; + ActiveIdx, DrivePct: Integer); +var + i: Integer; + WasLoading: Boolean; +begin + if FCmbTXProfile = nil then Exit; + WasLoading := FLoading; + FLoading := True; // заполнение списка не должно шибать OnChange + try + FCmbTXProfile.Items.Clear; + for i := 0 to High(Names) do FCmbTXProfile.Items.Add(Names[i]); + if (ActiveIdx >= 0) and (ActiveIdx < FCmbTXProfile.Items.Count) then + FCmbTXProfile.ItemIndex := ActiveIdx; + if FBtnTXProfDel <> nil then + FBtnTXProfDel.Enabled := FCmbTXProfile.Items.Count > 1; + if FLblTXProfDrive <> nil then + FLblTXProfDrive.Caption := Format('Drive профиля: %d %%', [DrivePct]); + finally + FLoading := WasLoading; + end; +end; + +procedure TSettingsForm.SetTXProfileDrive(DrivePct: Integer); +begin + if FLblTXProfDrive <> nil then + FLblTXProfDrive.Caption := Format('Drive профиля: %d %%', [DrivePct]); +end; + +procedure TSettingsForm.ShowTransmitPage(SubIdx: Integer = 0); +begin + SelectPage(FPageTransmit, FNavTransmit); + SelectTXSubPage(SubIdx); + Show; + BringToFront; +end; + procedure TSettingsForm.LoadTXSettings(const T: TTXSettings; BoardType: Integer = 0); var i: Integer; IsOrion: Boolean; begin @@ -2708,6 +2969,8 @@ procedure TSettingsForm.ApplyTheme(const T: TAppTheme); begin if TPanel(Ctrl).Height = 1 then TPanel(Ctrl).Color := T.Border + else if TPanel(Ctrl).Tag = TAG_PAGE_BG then + TPanel(Ctrl).Color := T.BG // подложка подвкладки, не группа else TPanel(Ctrl).Color := T.Panel; WalkControls(TPanel(Ctrl));