From 543453007e2d436d6f2c9eb46a6a692bbaac00ba Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sun, 12 Jul 2026 23:06:12 +0300 Subject: [PATCH] =?UTF-8?q?feat(slices):=20=D0=BF=D0=B5=D1=80=D1=81=D0=B8?= =?UTF-8?q?=D1=81=D1=82=20=D0=BF=D0=B0=D0=BD=D0=BE=D0=B2+=D1=81=D0=BB?= =?UTF-8?q?=D0=B0=D0=B9=D1=81=D0=BE=D0=B2,=20=D1=80=D0=B0=D0=B7=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=20=D0=9A=D0=92/=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D1=81=D0=B2=D0=B5=D1=80=D1=82=D0=B5=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сохранение и восстановление панадаптеров, слайсов и раскладки per-device, раздельно по контексту: КВ ('hf') и каждый трансвертер ('xvtr_N') — паны одного контекста не имеют смысла в другом. Settings: секция "pans"/ под MAC (grid-режим/доли, на пан — freq/rate/adc/zoom/pan/доли стека+колонки+сплиттер спектр/водопад, на слайс — freq/mode/фильтр/agc/vol/mute/squelch/аудиоустройство). TSliceCfg/TPanCfg/ TPansConfig + Default/Save/LoadPans(Ctx). MainForm: CreatePanCore (вынос ядра из OnAddPanClick), SavePanWorkspaceCtx/ RestorePanWorkspaceCtx/TearDownWorkspace, PanContextKey. Хук контроллера OnBeforeStop (снимок до гашения DDC в StopRunning). Переходы ActivateXvtrBand/ DeactivateXvtr обёрнуты save-старого/teardown/restore-нового (гейт FRunning); restore XVTR-панов после ActivateXvtr (FCurrentXvtr целевой ⇒ XvtrTranslate верный). PanafallPanel.DestroyAllSliceFlags. Смена КВ-банда контекст не меняет. Round-trip сериализации и разделения контекстов проверены автономно; на экране не проверено. Co-Authored-By: Claude Opus 4.8 --- MainForm.pas | 257 ++++++++++++++++++++++++++++++++++++++++---- PanafallPanel.pas | 10 ++ RadioController.pas | 4 + Settings.pas | 178 ++++++++++++++++++++++++++++++ 4 files changed, 427 insertions(+), 22 deletions(-) diff --git a/MainForm.pas b/MainForm.pas index 4f4efc7..b57a483 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -189,6 +189,7 @@ type FPanGridRowShare: Double; // доля высоты верхнего ряда (пан 0), 0.15..0.85 FPanColShare: array[1..MAX_PANS-1] of Double; // доли ширины колонок FPanVSplitters: array[1..MAX_PANS-1] of TPanel; // слева от колонки пана i + FPansRestored: Boolean; // персист панов восстановлен в этой Run-сессии // Drag вертикального сплиттера (индекс пана справа; 0 = нет). FPanVSplitDrag: Integer; FPanVSplitX0: Integer; @@ -583,10 +584,20 @@ type function PanOfOverlay(Sender: TObject): TPanafallPanel; procedure PushFlagStateAllPans(SliceId: Integer); procedure LayoutFlagsAllPans; + function CreatePanCore(PanId: Integer; Freq: Double; + RateKHz: Word; ADC: Byte): TPanafallPanel; // DDC+панель (без слайса) procedure OnAddPanClick(Sender: TObject); // «⊞» пана 0 procedure OnPanHeaderClose(Sender: TObject); // «×» шапки пана procedure ClosePanadapter(PanId: Integer); procedure DestroyAllExtraPans; // stop/выход + function PanContextKey: string; // 'hf' | 'xvtr_N' + procedure TearDownWorkspace; // снос панов+слайсов контекста + procedure SavePanWorkspace; // OnBeforeStop: текущий контекст + procedure SavePanWorkspaceCtx(const Ctx: string); // снимок панов+слайсов контекста + procedure RestorePanWorkspace; // восстановление при Run (once) + procedure RestorePanWorkspaceCtx(const Ctx: string); // восстановление контекста Ctx + procedure RestoreSliceInto(P: TPanafallPanel; PanId: Integer; + const SC: TSliceCfg); procedure UpdatePanHeaders; // текст+видимость шапок procedure ApplyPanHeaderTheme(P: TPanafallPanel); // цвета шапки пана procedure UpdateAddPanButton; @@ -879,6 +890,8 @@ begin FChannelController.OnActiveChanged := OnChannelActiveChanged; // Канал-оркестрация фаером из контроллера в конце SetVfoA (любой путь тюнинга). FController.OnAfterTune := FChannelController.OnVfoTuned; + // Снимок панов+слайсов перед гашением DDC в StopRunning (персист). + FController.OnBeforeStop := SavePanWorkspace; FController.FVfoA := 14200000; FController.FVfoB := 7100000; FController.FActiveVfo := 0; @@ -1337,6 +1350,7 @@ begin BtnMOX.Enabled := True; UpdateAddPanButton; // «⊞» доступна только в Run ResizeSpectrumPanels; + RestorePanWorkspace; // паны+слайсы прошлой сессии end else begin @@ -1344,6 +1358,10 @@ begin BtnStartStop.Caption := 'START'; StyleButton(BtnStartStop, False); BtnMOX.Enabled := False; + // Снимок панов уже сделан хуком OnBeforeStop=SavePanWorkspace (до + // гашения DDC в StopRunning). Здесь только разрешаем повторный + // restore на следующем старте. + FPansRestored := False; // Доп. паны живут только в Run-сессии (их DDC уже погашены // контроллером в StopRunning) — убираем и UI. DestroyAllExtraPans; @@ -5841,31 +5859,18 @@ begin FController.FRunning and HasSlot; end; -procedure TMainForm.OnAddPanClick(Sender: TObject); -// «⊞»: новый пан на своём DDC. Центр = частота активного VFO, rate 384к, -// ADC 0; в центре автоматически создаётся первый слайс. +function TMainForm.CreatePanCore(PanId: Integer; Freq: Double; + RateKHz: Word; ADC: Byte): TPanafallPanel; +// Ядро создания доп. пана: DDC + панель + проводка + сплиттеры + регистрация +// в FPans. НЕ создаёт слайс, НЕ трогает раскладку/шапки — это делают +// вызывающие (OnAddPanClick — новый пан; RestorePanWorkspace — восстановление). +// Возвращает nil, если DDC не создался. var - PanId, i: Integer; - RateKHz: Word; - Freq: Double; P: TPanafallPanel; Split: TPanel; begin - if not FController.FRunning then Exit; - PanId := -1; - for i := 1 to MAX_PANS - 1 do - if FPans[i] = nil then begin PanId := i; Break; end; - if PanId < 0 then Exit; - - // Rate наследуется от главного пана с клампом сверху (768к — потолок доп. - // пана по Ethernet/CPU; 48/96/192к наследуются как есть). Не из сетки - // (напр. Pluto-подобные) — контроллер откатит на 384к. Дальше rate пана - // независим: селектор «NNN kHz» в шапке, за сменой rate главного НЕ следует. - RateKHz := FController.FSampleRate div 1000; - if RateKHz > 768 then RateKHz := 768; - - Freq := ActiveVfoFreq; - if not FController.AddPanDDC(PanId, Freq, RateKHz, 0) then Exit; + Result := nil; + if not FController.AddPanDDC(PanId, Freq, RateKHz, ADC) then Exit; P := TPanafallPanel.Create(nil, FUseOpenGLSpectrum); P.PanId := PanId; @@ -5942,13 +5947,42 @@ begin FPanVSplitters[PanId] := Split; FPanColShare[PanId] := 1.0; - // Публикуем пан для пиксельных колбэков (display-поток) и делим высоту поровну. + // Публикуем пан для пиксельных колбэков (display-поток). FPanCbLock.Enter; try FPans[PanId] := P; finally FPanCbLock.Leave; end; + Result := P; +end; + +procedure TMainForm.OnAddPanClick(Sender: TObject); +// «⊞»: новый пан на своём DDC. Центр = частота активного VFO, rate наследуется +// от главного (кламп 768к); ADC 0; в центре автоматически создаётся слайс. +var + PanId, i: Integer; + RateKHz: Word; + Freq: Double; + P: TPanafallPanel; +begin + if not FController.FRunning then Exit; + PanId := -1; + for i := 1 to MAX_PANS - 1 do + if FPans[i] = nil then begin PanId := i; Break; end; + if PanId < 0 then Exit; + + // Rate наследуется от главного пана с клампом сверху (768к — потолок доп. + // пана по Ethernet/CPU; 48/96/192к наследуются как есть). Не из сетки + // (напр. Pluto-подобные) — контроллер откатит на 384к. Дальше rate пана + // независим: селектор «NNN kHz» в шапке, за сменой rate главного НЕ следует. + RateKHz := FController.FSampleRate div 1000; + if RateKHz > 768 then RateKHz := 768; + + Freq := ActiveVfoFreq; + P := CreatePanCore(PanId, Freq, RateKHz, 0); + if P = nil then Exit; + for i := 0 to MAX_PANS - 1 do if FPans[i] <> nil then FPanShare[i] := 1.0 / PanCount; @@ -6011,6 +6045,163 @@ begin if FPans[i] <> nil then ClosePanadapter(i); end; +function TMainForm.PanContextKey: string; +// Ключ контекста персиста панов: КВ и каждый трансвертер — раздельно +// (паны/слайсы одного контекста не имеют смысла в другом). +begin + if FController.FCurrentXvtr < 0 then + Result := 'hf' + else + Result := 'xvtr_' + IntToStr(FController.FCurrentXvtr); +end; + +procedure TMainForm.TearDownWorkspace; +// Полный снос панов+слайсов текущего контекста (доп. паны + слайсы пана 0). +begin + DestroyAllExtraPans; + FPan.DestroyAllSliceFlags; + FActiveSliceId := 0; +end; + +procedure TMainForm.SavePanWorkspace; +// OnBeforeStop-хук: снимок текущего контекста перед гашением панов. +begin + SavePanWorkspaceCtx(PanContextKey); +end; + +procedure TMainForm.SavePanWorkspaceCtx(const Ctx: string); +// Снимок панов+слайсов+раскладки в секцию "pans"/Ctx под MAC. Зовётся при STOP/ +// закрытии/смене контекста (пока железо ещё подключено), до разрушения панов. +var + Cfg: TPansConfig; + i, s, n: Integer; +begin + if not FController.FNetwork.Connected then Exit; // MAC валиден только при девайсе + TSettingsManager.DefaultPans(Cfg); + Cfg.GridMode := FPanGridMode; + Cfg.GridRowShare := FPanGridRowShare; + for i := 0 to MAX_PANS - 1 do + begin + Cfg.Pans[i].StackShare := FPanShare[i]; + if FPans[i] <> nil then Cfg.Pans[i].SplitRatio := FPans[i].SplitterRatio; + if i > 0 then + begin + if not FController.PanDDCActive(i) then Continue; // слайсы мёртвого пана не пишем + Cfg.Pans[i].Active := True; + Cfg.Pans[i].FreqHz := FController.PanDDCFreq(i); + Cfg.Pans[i].RateKHz := FController.PanDDCRateKHz(i); + Cfg.Pans[i].ADC := FController.PanADC(i); + Cfg.Pans[i].Zoom := FController.PanZoomFactor(i); + Cfg.Pans[i].PanSlider := FController.PanPanSlider(i); + Cfg.Pans[i].ColShare := FPanColShare[i]; + end; + // Слайсы этого пана (главный VFO пана 0 — не слайс, его тут нет). + n := 0; + for s := 0 to High(FController.FSlices) do + if FController.FSlices[s].Active and (FController.FSlices[s].PanId = i) then + Inc(n); + SetLength(Cfg.Pans[i].Slices, n); + n := 0; + for s := 0 to High(FController.FSlices) do + if FController.FSlices[s].Active and (FController.FSlices[s].PanId = i) then + begin + Cfg.Pans[i].Slices[n].TargetHz := FController.FSlices[s].TargetHz; + Cfg.Pans[i].Slices[n].Mode := FController.FSlices[s].Mode; + Cfg.Pans[i].Slices[n].FilterLo := FController.FSlices[s].FilterLo; + Cfg.Pans[i].Slices[n].FilterHi := FController.FSlices[s].FilterHi; + Cfg.Pans[i].Slices[n].AGC := Ord(FController.FSlices[s].AGC); + Cfg.Pans[i].Slices[n].Volume := FController.FSlices[s].Volume; + Cfg.Pans[i].Slices[n].Muted := FController.FSlices[s].Muted; + Cfg.Pans[i].Slices[n].FMSQOn := FController.FSlices[s].FMSQOn; + Cfg.Pans[i].Slices[n].FMSQLevel := FController.FSlices[s].FMSQLevel; + Cfg.Pans[i].Slices[n].DevName := FController.FSlices[s].DevName; + Inc(n); + end; + end; + FController.FSettings.SavePans(FController.FDevMAC, Ctx, Cfg); + FController.FSettings.Save; +end; + +procedure TMainForm.RestoreSliceInto(P: TPanafallPanel; PanId: Integer; + const SC: TSliceCfg); +// Один слайс в пан P (PanId): создаёт WDSP-канал + флаг, применяет mute/squelch. +var Id, DevIdx: Integer; +begin + if not FController.SliceFitsCapture(SC.TargetHz, PanId) then Exit; // вне полосы захвата + DevIdx := -1; + if SC.DevName <> '' then + DevIdx := FController.FAudioOut.FindDeviceByName(SC.DevName); + Id := FController.AddSlice(SC.TargetHz, SC.Mode, SC.FilterLo, SC.FilterHi, + TWDSPAGCMode(EnsureRange(SC.AGC, 0, Ord(High(TWDSPAGCMode)))), + EnsureRange(SC.Volume, 0.0, 1.0), DevIdx, SC.DevName, PanId); + if Id < 0 then Exit; + if SC.Muted then FController.SetSliceMute(Id, True); + if SC.FMSQOn then FController.SetSliceFMSquelch(Id, True, SC.FMSQLevel); + P.CreateSliceFlag(Id); + FActiveSliceId := Id; +end; + +procedure TMainForm.RestorePanWorkspace; +// Восстановление контекста при выходе в Run (один раз за сессию). Контекст +// (КВ|трансвертер) уже финальный — ConnectAndRun зовёт StartRunning последним. +begin + if not FController.FRunning then Exit; + if FPansRestored then Exit; + FPansRestored := True; + // Слайсы пана 0 переживают STOP (софт-fan-out) — снести выживших, иначе дубли. + TearDownWorkspace; + RestorePanWorkspaceCtx(PanContextKey); +end; + +procedure TMainForm.RestorePanWorkspaceCtx(const Ctx: string); +// Восстановление панов+слайсов+раскладки контекста Ctx. Вызывающий обязан +// СНАЧАЛА снести текущий воркспейс (TearDownWorkspace). При отсутствии +// сохранёнки применяются дефолты (чистый стек, без панов). +var + Cfg: TPansConfig; + i, k: Integer; + P: TPanafallPanel; +begin + if not FController.FRunning then Exit; + // LoadPans заполняет Cfg дефолтами и при отсутствии секции — применяем как есть + // (грид off, панов нет) ради чистого целевого контекста. + FController.FSettings.LoadPans(FController.FDevMAC, Ctx, Cfg); + + FPanGridMode := Cfg.GridMode; + FPanGridRowShare := Cfg.GridRowShare; + + // Пан 0: восстановить его доп. слайсы (главный VFO — не слайс) + доли. + FPanShare[0] := Cfg.Pans[0].StackShare; + FPan.SplitterRatio := Cfg.Pans[0].SplitRatio; + for k := 0 to High(Cfg.Pans[0].Slices) do + RestoreSliceInto(FPan, 0, Cfg.Pans[0].Slices[k]); + + // Доп. паны на своих DDC. + for i := 1 to MAX_PANS - 1 do + begin + if not Cfg.Pans[i].Active then Continue; + if FPans[i] <> nil then Continue; + P := CreatePanCore(i, Cfg.Pans[i].FreqHz, + Word(EnsureRange(Cfg.Pans[i].RateKHz, 48, 1536)), + Byte(EnsureRange(Cfg.Pans[i].ADC, 0, 3))); + if P = nil then Continue; + FPanShare[i] := Cfg.Pans[i].StackShare; + FPanColShare[i] := Cfg.Pans[i].ColShare; + P.SplitterRatio := Cfg.Pans[i].SplitRatio; + FController.SetPanZoomPan(i, Cfg.Pans[i].Zoom, Cfg.Pans[i].PanSlider); + for k := 0 to High(Cfg.Pans[i].Slices) do + RestoreSliceInto(P, i, Cfg.Pans[i].Slices[k]); + end; + + ApplyDarkTheme; + UpdatePanHeaders; + UpdateAddPanButton; + ResizeSpectrumPanels; + // Зум-бары/флаги — после раскладки (размеры вьюх уже верны). + for i := 1 to MAX_PANS - 1 do + if FPans[i] <> nil then RefreshPanZoom(FPans[i]); +end; + procedure TMainForm.SyncPanViews; // Центр/span вьюх доп. панов (аналог SyncSpecViewFreq главного, но без // VFO/TX-маркеров — они уведены за окно при создании). @@ -6860,20 +7051,42 @@ end; // Активирует XVTR-band: устанавливает видимую частоту по LastFreq или // середине [FreqBegin..FreqEnd], применяет network XVTR-режим. procedure TMainForm.ActivateXvtrBand(Idx: Integer); +var Running: Boolean; begin + // Смена контекста персиста панов: сохранить старый (КВ|прежний XVTR), снести, + // войти в слот, восстановить целевой. Паны живут только в Run. + Running := FController.FRunning; + if Running then + begin + SavePanWorkspaceCtx(PanContextKey); // текущий контекст (до смены FCurrentXvtr) + TearDownWorkspace; + end; // Тонкий делегат: вход в XVTR-слот + рендер кнопок/частот/спектра/wideband/web — // всё в команде/событиях (rfXvtr/rfBand/rfActiveVfo). FController.ActivateXvtr(Idx); + if Running then + RestorePanWorkspaceCtx(PanContextKey); // новый контекст (xvtr_Idx) end; // Деактивирует XVTR — возвращает к HF-режиму. FController.FCurrentBand указывает // на HF-диапазон, с которого пользователь зашёл в трансвертер. RestoreBand вернёт // VFO/Mode/Filter/AGC/CTUN/Span этого диапазона и обновит подсветку HF band-кнопки. procedure TMainForm.DeactivateXvtr; +var Running: Boolean; begin + // Выход из XVTR в КВ = смена контекста персиста панов: сохранить XVTR-контекст, + // снести, вернуться в HF, восстановить 'hf'. + Running := FController.FRunning; + if Running then + begin + SavePanWorkspaceCtx(PanContextKey); // текущий XVTR-контекст (до сброса FCurrentXvtr) + TearDownWorkspace; + end; // Тонкий делегат: выход в HF (SaveCurrentBand + HF-возврат RestoreBand + рендер // кнопок/wideband/web) — всё в команде/событиях. FController.DeactivateXvtr; + if Running then + RestorePanWorkspaceCtx(PanContextKey); // контекст 'hf' end; procedure TMainForm.OnXvtrSettingsChange(const X: TXvtrSettings); diff --git a/PanafallPanel.pas b/PanafallPanel.pas index 116078d..d980dd2 100644 --- a/PanafallPanel.pas +++ b/PanafallPanel.pas @@ -244,6 +244,7 @@ type function FindSliceFlag(SliceId: Integer): TVfoOverlay; procedure CreateSliceFlag(SliceId: Integer); procedure DestroySliceFlag(SliceId: Integer); + procedure DestroyAllSliceFlags; // сброс всех слайсов панели (+RemoveSlice) // «×» на флаге: нельзя free внутри его же обработчика мыши — пометить... procedure RequestSliceClose(SliceId: Integer); // …и снять после возврата из диспетчера. @@ -1004,6 +1005,15 @@ begin if Assigned(FOnFlagsInvalidate) then FOnFlagsInvalidate(Self); // Self = панель (роутинг по пану) end; +procedure TPanafallPanel.DestroyAllSliceFlags; +// Полный сброс слайсов панели (каждый DestroySliceFlag зовёт RemoveSlice + +// сжимает FSliceFlags). Нужен персисту: слайсы пана 0 переживают STOP, а +// восстановление создаёт их заново — иначе дубли. +begin + while Length(FSliceFlags) > 0 do + DestroySliceFlag(FSliceFlags[0].SliceId); +end; + procedure TPanafallPanel.RequestSliceClose(SliceId: Integer); begin FPendingSliceClose := SliceId; diff --git a/RadioController.pas b/RadioController.pas index 062d51c..c1f08ba 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -155,6 +155,7 @@ type FOnStateChanged: TRadioStateEvent; FStateListeners: array of TRadioStateEvent; // доп. наблюдатели (CAT/Andromeda), помимо FOnStateChanged (MainForm) FOnAfterTune: TThreadMethod; // пост-тюн хук: канал-оркестрация после SetVfoA + FOnBeforeStop: TThreadMethod; // хук перед гашением панов в StopRunning (персист панов) FOnAudioConsume: TAudioConsumeEvent; // web-перехват RX-аудио (если активен клиент) FLocalAudio: Boolean; // локальный звук (динамик/мик ПК): GUI=True, демон=False FOnSpectrumData: TPixelDataEvent; // сырьё спектра наружу (UI рисует FSpecView) @@ -740,6 +741,7 @@ type // рендера) — сюда вешается канальная оркестрация (ChannelController.OnVfoTuned), // чтобы любой путь тюнинга (desktop/web/CAT/энкодер) был канал-aware headless. property OnAfterTune: TThreadMethod read FOnAfterTune write FOnAfterTune; + property OnBeforeStop: TThreadMethod read FOnBeforeStop write FOnBeforeStop; property OnAudioConsume: TAudioConsumeEvent read FOnAudioConsume write FOnAudioConsume; // Локальный звук ПК (динамик RX / микрофон TX). Демон ставит False: аудио // только через web (OnAudioConsume), локальная звуковая карта не трогается. @@ -3915,6 +3917,8 @@ var begin if FRunning then begin + // Персист панов/слайсов ДО их гашения (UI-снимок живого состояния). + if Assigned(FOnBeforeStop) then FOnBeforeStop; // Паны на доп. DDC живут только внутри Run-сессии — гасим до Run=0 // (3.3 UI пересоздаст по персисту при следующем старте). for p := 1 to MAX_PANS - 1 do diff --git a/Settings.pas b/Settings.pas index e825d8b..2ce699e 100644 --- a/Settings.pas +++ b/Settings.pas @@ -12,6 +12,7 @@ const SETTINGS_FILE = 'hpsdr_settings.json'; CFG_BAND_COUNT = 12; CFG_XVTR_COUNT = 8; // количество XVTR-слотов (трансверторов) + CFG_PANS_MAX = 4; // потолок панадаптеров (пан 0 + доп.) — = WDSPEngine.MAX_PANS QO100_SLOT = CFG_XVTR_COUNT - 1; // последний слот зарезервирован под QO-100 // (split-LO транспондер; настраивается на // отдельной странице, не в общей XVTR-таблице) @@ -83,6 +84,41 @@ type PanSlider: Double; // положение окна зума 0..1 (0.5 = центр) end; + // Персист панадаптеров и слайсов (секция "pans" под MAC). Восстанавливается + // один раз при выходе в Run. Слайс = приёмный WDSP-канал внутри пана; частоты + // абсолютные (видимые). См. [[project_slices_plan]]. + TSliceCfg = record + TargetHz: Double; // абсолютная видимая частота слайса + Mode: Integer; + FilterLo: Integer; + FilterHi: Integer; + AGC: Integer; // Ord(TWDSPAGCMode) + Volume: Double; // 0..1 + Muted: Boolean; + FMSQOn: Boolean; + FMSQLevel: Integer; + DevName: string; // имя PortAudio-устройства ('' = default) + end; + + TPanCfg = record + Active: Boolean; // пан 1..N существует (пан 0 всегда есть) + FreqHz: Double; // видимый центр DDC (только паны N) + RateKHz: Integer; + ADC: Integer; + Zoom: Double; // 0..1 + PanSlider: Double; // 0..1 + StackShare: Double; // доля высоты в стеке панов + ColShare: Double; // доля ширины в грид-раскладке + SplitRatio: Double; // доля высоты спектра (сплиттер спектр/водопад) + Slices: array of TSliceCfg; + end; + + TPansConfig = record + GridMode: Boolean; // грид-раскладка (▦) vs вертикальный стек (▤) + GridRowShare: Double; // доля высоты пана 0 в гриде + Pans: array[0..CFG_PANS_MAX-1] of TPanCfg; // [0]=главный (только Slices) + end; + // TX-настройки per-device. Хранятся в JSON-секции "tx" под MAC, // отдельно от global/bands. Применяются к WDSP через SetTX* методы. TTXSettings = record @@ -371,6 +407,11 @@ type class procedure DefaultXvtr(out X: TXvtrSettings); function LoadXvtr(const MAC: array of Byte; out X: TXvtrSettings): Boolean; procedure SaveXvtr(const MAC: array of Byte; const X: TXvtrSettings); + // Панадаптеры + слайсы per-device (JSON-секция "pans" под MAC). Ctx — + // ключ контекста ('hf' или 'xvtr_N'): КВ и каждый трансвертер раздельно. + class procedure DefaultPans(out P: TPansConfig); + function LoadPans(const MAC: array of Byte; const Ctx: string; out P: TPansConfig): Boolean; + procedure SavePans(const MAC: array of Byte; const Ctx: string; const P: TPansConfig); // Web-сервер — глобальные настройки (секция "web" в корне JSON). class procedure DefaultWeb(out W: TWebSettings); procedure LoadWebSettings(out W: TWebSettings); @@ -1547,4 +1588,141 @@ begin Result := JB(O, 'light_theme', False); end; +// --------------------------------------------------------------------------- +// Панадаптеры + слайсы (per-device, JSON-секция "pans" под MAC) +// --------------------------------------------------------------------------- + +class procedure TSettingsManager.DefaultPans(out P: TPansConfig); +var i: Integer; +begin + P.GridMode := False; + P.GridRowShare := 0.5; + for i := 0 to CFG_PANS_MAX-1 do + begin + P.Pans[i].Active := False; + P.Pans[i].FreqHz := 0.0; + P.Pans[i].RateKHz := 384; + P.Pans[i].ADC := 0; + P.Pans[i].Zoom := 0.0; + P.Pans[i].PanSlider := 0.5; + P.Pans[i].StackShare := 1.0; + P.Pans[i].ColShare := 1.0; + P.Pans[i].SplitRatio := 0.40; // = TPanafallPanel дефолт + SetLength(P.Pans[i].Slices, 0); + end; +end; + +procedure TSettingsManager.SavePans(const MAC: array of Byte; const Ctx: string; + const P: TPansConfig); +var + Root, PanObj, SliceObj: TJSONObject; + PanArr, SliceArr: TJSONArray; + i, j, Idx: Integer; +begin + Root := EnsureObj(EnsureObj(GetDevObj(MacToStr(MAC)), 'pans'), Ctx); + JW(Root, 'grid_mode', P.GridMode); + JW(Root, 'grid_row_share', P.GridRowShare); + // Список панов пересоздаётся целиком (проще, чем merge по индексам). + Idx := Root.IndexOfName('list'); + if Idx >= 0 then Root.Delete(Idx); + PanArr := TJSONArray.Create; + Root.Add('list', PanArr); + for i := 0 to CFG_PANS_MAX-1 do + begin + // Пан 0 пишем всегда (ради его доли стека/слайсов), паны N — только активные. + if (i > 0) and not P.Pans[i].Active then Continue; + PanObj := TJSONObject.Create; + PanArr.Add(PanObj); + PanObj.Add('id', i); + PanObj.Add('active', P.Pans[i].Active); + PanObj.Add('freq', P.Pans[i].FreqHz); + PanObj.Add('rate_khz', P.Pans[i].RateKHz); + PanObj.Add('adc', P.Pans[i].ADC); + PanObj.Add('zoom', P.Pans[i].Zoom); + PanObj.Add('pan', P.Pans[i].PanSlider); + PanObj.Add('stack_share', P.Pans[i].StackShare); + PanObj.Add('col_share', P.Pans[i].ColShare); + PanObj.Add('split_ratio', P.Pans[i].SplitRatio); + SliceArr := TJSONArray.Create; + PanObj.Add('slices', SliceArr); + for j := 0 to High(P.Pans[i].Slices) do + begin + SliceObj := TJSONObject.Create; + SliceArr.Add(SliceObj); + SliceObj.Add('target_hz', P.Pans[i].Slices[j].TargetHz); + SliceObj.Add('mode', P.Pans[i].Slices[j].Mode); + SliceObj.Add('filter_lo', P.Pans[i].Slices[j].FilterLo); + SliceObj.Add('filter_hi', P.Pans[i].Slices[j].FilterHi); + SliceObj.Add('agc', P.Pans[i].Slices[j].AGC); + SliceObj.Add('volume', P.Pans[i].Slices[j].Volume); + SliceObj.Add('muted', P.Pans[i].Slices[j].Muted); + SliceObj.Add('fmsq_on', P.Pans[i].Slices[j].FMSQOn); + SliceObj.Add('fmsq_level', P.Pans[i].Slices[j].FMSQLevel); + SliceObj.Add('dev_name', P.Pans[i].Slices[j].DevName); + end; + end; +end; + +function TSettingsManager.LoadPans(const MAC: array of Byte; const Ctx: string; + out P: TPansConfig): Boolean; +var + MacStr: string; + DevObj, PansObj, Root, PanObj, SliceObj: TJSONObject; + ListData, SlData: TJSONData; + Arr, SArr: TJSONArray; + i, id, k, n: Integer; +begin + DefaultPans(P); + MacStr := MacToStr(MAC); + Result := FRoot.Find(MacStr) <> nil; + if not Result then Exit; + DevObj := GetDevObj(MacStr); + if DevObj.Find('pans') = nil then begin Result := False; Exit; end; + PansObj := EnsureObj(DevObj, 'pans'); + if PansObj.Find(Ctx) = nil then begin Result := False; Exit; end; + Root := EnsureObj(PansObj, Ctx); + P.GridMode := JB(Root, 'grid_mode', False); + P.GridRowShare := EnsureRange(JD(Root, 'grid_row_share', 0.5), 0.15, 0.85); + ListData := Root.Find('list'); + if (ListData = nil) or not (ListData is TJSONArray) then Exit; + Arr := TJSONArray(ListData); + for i := 0 to Arr.Count-1 do + begin + if not (Arr.Items[i] is TJSONObject) then Continue; + PanObj := TJSONObject(Arr.Items[i]); + id := JI(PanObj, 'id', -1); + if (id < 0) or (id >= CFG_PANS_MAX) then Continue; + P.Pans[id].Active := JB(PanObj, 'active', id > 0); + P.Pans[id].FreqHz := JD(PanObj, 'freq', 0.0); + P.Pans[id].RateKHz := JI(PanObj, 'rate_khz', 384); + P.Pans[id].ADC := JI(PanObj, 'adc', 0); + P.Pans[id].Zoom := EnsureRange(JD(PanObj, 'zoom', 0.0), 0.0, 1.0); + P.Pans[id].PanSlider := EnsureRange(JD(PanObj, 'pan', 0.5), 0.0, 1.0); + P.Pans[id].StackShare := JD(PanObj, 'stack_share', 1.0); + P.Pans[id].ColShare := JD(PanObj, 'col_share', 1.0); + P.Pans[id].SplitRatio := EnsureRange(JD(PanObj, 'split_ratio', 0.40), 0.05, 0.95); + SlData := PanObj.Find('slices'); + if (SlData = nil) or not (SlData is TJSONArray) then Continue; + SArr := TJSONArray(SlData); + SetLength(P.Pans[id].Slices, 0); + for k := 0 to SArr.Count-1 do + begin + if not (SArr.Items[k] is TJSONObject) then Continue; + SliceObj := TJSONObject(SArr.Items[k]); + n := Length(P.Pans[id].Slices); + SetLength(P.Pans[id].Slices, n+1); + P.Pans[id].Slices[n].TargetHz := JD(SliceObj, 'target_hz', 0.0); + P.Pans[id].Slices[n].Mode := JI(SliceObj, 'mode', 0); + P.Pans[id].Slices[n].FilterLo := JI(SliceObj, 'filter_lo', -1500); + P.Pans[id].Slices[n].FilterHi := JI(SliceObj, 'filter_hi', 1500); + P.Pans[id].Slices[n].AGC := JI(SliceObj, 'agc', 1); + P.Pans[id].Slices[n].Volume := EnsureRange(JD(SliceObj, 'volume', 0.7), 0.0, 1.0); + P.Pans[id].Slices[n].Muted := JB(SliceObj, 'muted', False); + P.Pans[id].Slices[n].FMSQOn := JB(SliceObj, 'fmsq_on', False); + P.Pans[id].Slices[n].FMSQLevel := JI(SliceObj, 'fmsq_level', 30); + P.Pans[id].Slices[n].DevName := JS(SliceObj, 'dev_name', ''); + end; + end; +end; + end.