feat(slices): персист панов+слайсов, раздельно КВ/трансвертеры

Сохранение и восстановление панадаптеров, слайсов и раскладки per-device,
раздельно по контексту: КВ ('hf') и каждый трансвертер ('xvtr_N') — паны
одного контекста не имеют смысла в другом.

Settings: секция "pans"/<ctx> под 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 23:06:12 +03:00
co-authored by Claude Opus 4.8
parent b3edf3a4ad
commit 543453007e
4 changed files with 427 additions and 22 deletions
+178
View File
@@ -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.