feat(tx): привязка профиля к паре «флаг + модуляция»

Одного значения на флаг мало: на слайсе с FT8 и на нём же голосом нужен разный
звук, а слайс/главный меняют модуляцию на лету. Привязка стала таблицей
TTXProfModeTable (режим → индекс профиля, -1 = не переключать), своя у каждого
флага: MainByMode у главного (одна на устройство, общая для КВ и трансвертера)
и TXProfByMode у слайса (сама разъезжается по контекстам — записи слайсов
хранятся отдельно для КВ и трансвертера).

Ключ — режим САМОГО ФЛАГА (FlagMode): у главного FMode, у слайса свой. Выбор в
пилюле пишется в ячейку текущего режима, поэтому смена модуляции достаёт
профиль, с которым в этом виде работали в прошлый раз.

Применение — в трёх точках, все ДО пуша режима в WDSP (ApplyTXChainSettings
внутри SetTXMode перешьёт цепь уже новыми значениями): SetTxSlice, SetMode
(если передаём с главного) и SetSliceMode (если источник — этот слайс).

Персист: tx_prof_modes в записи слайса, main_prof_modes в секции tx_profiles.
Чтение защитное — индекс вне текущего списка профилей читается как -1.
Round-trip проверен прогоном: обе таблицы переживают запись/чтение, незаданные
режимы -1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 17:58:46 +03:00
co-authored by Claude Opus 5
parent 013d0406ac
commit 36af031767
3 changed files with 143 additions and 30 deletions
+58 -12
View File
@@ -164,6 +164,10 @@ type
// Персист панадаптеров и слайсов (секция "pans" под MAC). Восстанавливается
// один раз при выходе в Run. Слайс = приёмный WDSP-канал внутри пана; частоты
// абсолютные (видимые). См. [[project_slices_plan]].
// Привязка «режим → индекс TX-профиля» (-1 = не переключать). Такая таблица
// есть у каждого флага: у главного в TTXProfileList, у слайса в TSliceCfg.
TTXProfModeTable = array[0..CFG_MODE_MAX] of Integer;
TSliceCfg = record
TargetHz: Double; // абсолютная видимая частота слайса
Mode: Integer;
@@ -177,7 +181,9 @@ type
FMSQLevel: Integer;
DevName: string; // имя PortAudio-устройства вывода ('' = default)
InDevName: string; // имя устройства ВВОДА слайса ('' = общий вход)
TXProfile: Integer; // TX-профиль слайса: -1 = какой активен, тот и звучит
// TX-профиль слайса ПО РЕЖИМУ: -1 = какой активен, тот и звучит. Таблица,
// а не одно значение: слайс на FT8 и слайс голосом помнят каждый своё.
TXProfByMode: TTXProfModeTable;
end;
TPanCfg = record
@@ -350,9 +356,12 @@ type
TTXProfileList = record
Count: Integer;
ActiveIdx: Integer;
// Привязка профиля к ГЛАВНОМУ приёмнику (слайс A). У слайсов B..G то же
// самое лежит в TSliceCfg.TXProfile; -1 = ничего не переключать.
MainBind: Integer;
// Привязка профиля к ГЛАВНОМУ приёмнику (слайс A) ПО РЕЖИМУ; -1 = ничего
// не переключать. У слайсов B..G своя такая же в TSliceCfg.TXProfByMode.
// Таблица главного одна на устройство — общая для КВ и трансвертера
// (решение пользователя); у слайсов она сама разъезжается по контекстам,
// потому что их записи хранятся отдельно для КВ и трансвертера.
MainByMode: TTXProfModeTable;
Items: array[0..TXPROF_MAX-1] of TTXProfile;
end;
@@ -701,6 +710,11 @@ procedure TXProfileApply(const P: TTXProfile; var T: TTXSettings; var DrivePct:
// (иначе переключение на «ESSB» сбрасывало бы Mic In/Line In и drive).
procedure DefaultTXProfiles(const Base: TTXSettings; DrivePct: Integer;
out L: TTXProfileList);
// Таблица «режим → профиль» ↔ JSON-массив. Все элементы вне 0..TXPROF_MAX-1
// читаются как -1 («не переключать»), поэтому чужой/старый конфиг безопасен.
procedure ClearTXProfModeTable(var T: TTXProfModeTable);
function ModeTableToJSON(const T: TTXProfModeTable): TJSONArray;
procedure JSONToModeTable(Node: TJSONData; var T: TTXProfModeTable);
// Сколько пресетов реально есть у режима (VAR1/VAR2 идут сверх этого числа).
function FilterPresetCount(Mode: Integer): Integer;
@@ -1866,6 +1880,34 @@ begin
DrivePct := EnsureRange(P.DrivePercent, 0, 100);
end;
procedure ClearTXProfModeTable(var T: TTXProfModeTable);
var i: Integer;
begin
for i := 0 to CFG_MODE_MAX do T[i] := -1;
end;
function ModeTableToJSON(const T: TTXProfModeTable): TJSONArray;
var i: Integer;
begin
Result := TJSONArray.Create;
for i := 0 to CFG_MODE_MAX do Result.Add(T[i]);
end;
procedure JSONToModeTable(Node: TJSONData; var T: TTXProfModeTable);
var
Arr: TJSONArray;
i, V: Integer;
begin
ClearTXProfModeTable(T);
if not (Node is TJSONArray) then Exit;
Arr := TJSONArray(Node);
for i := 0 to Min(CFG_MODE_MAX, Arr.Count - 1) do
begin
V := Arr.Integers[i];
if (V >= 0) and (V < TXPROF_MAX) then T[i] := V;
end;
end;
procedure DefaultTXProfiles(const Base: TTXSettings; DrivePct: Integer;
out L: TTXProfileList);
const
@@ -1942,7 +1984,7 @@ begin
FillChar(L, SizeOf(L), 0);
L.Count := 0;
L.ActiveIdx := 0;
L.MainBind := -1; // главный приёмник профиль не переключает
ClearTXProfModeTable(L.MainByMode); // главный ничего не переключает
// «Default» — текущее состояние устройства (микрофонный вход, полоса,
// динамика, мощность), но с заводским EQ: после миграции старого конфига
// звук в эфире не меняется, а сброс к заводским возвращает ровный тембр.
@@ -1975,7 +2017,7 @@ begin
FillChar(L, SizeOf(L), 0);
L.Count := 0;
L.ActiveIdx := 0;
L.MainBind := -1;
ClearTXProfModeTable(L.MainByMode);
Result := False;
MacStr := MacToStr(MAC);
if FRoot.Find(MacStr) = nil then Exit;
@@ -2036,8 +2078,9 @@ begin
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;
L.MainBind := JI(Root, 'main_bind', -1);
if (L.MainBind < -1) or (L.MainBind >= L.Count) then L.MainBind := -1;
JSONToModeTable(Root.Find('main_prof_modes'), L.MainByMode);
for i := 0 to CFG_MODE_MAX do
if L.MainByMode[i] >= L.Count then L.MainByMode[i] := -1;
Result := True;
end;
@@ -2050,7 +2093,9 @@ var
begin
Root := EnsureObj(GetDevObj(MacToStr(MAC)), 'tx_profiles');
JW(Root, 'active', EnsureRange(L.ActiveIdx, 0, Max(0, L.Count - 1)));
JW(Root, 'main_bind', EnsureRange(L.MainBind, -1, Max(-1, L.Count - 1)));
Idx := Root.IndexOfName('main_prof_modes');
if Idx >= 0 then Root.Delete(Idx);
Root.Add('main_prof_modes', ModeTableToJSON(L.MainByMode));
// Список пересоздаётся целиком: удаление профиля не должно оставлять хвост.
Idx := Root.IndexOfName('list');
if Idx >= 0 then Root.Delete(Idx);
@@ -2741,7 +2786,8 @@ begin
SliceObj.Add('fmsq_level', P.Pans[i].Slices[j].FMSQLevel);
SliceObj.Add('dev_name', P.Pans[i].Slices[j].DevName);
SliceObj.Add('in_dev_name', P.Pans[i].Slices[j].InDevName);
SliceObj.Add('tx_profile', P.Pans[i].Slices[j].TXProfile);
SliceObj.Add('tx_prof_modes',
ModeTableToJSON(P.Pans[i].Slices[j].TXProfByMode));
end;
end;
end;
@@ -2822,8 +2868,8 @@ begin
P.Pans[id].Slices[n].FMSQLevel := JI(SliceObj, 'fmsq_level', 30);
P.Pans[id].Slices[n].DevName := JS(SliceObj, 'dev_name', '');
P.Pans[id].Slices[n].InDevName := JS(SliceObj, 'in_dev_name', '');
P.Pans[id].Slices[n].TXProfile := EnsureRange(
JI(SliceObj, 'tx_profile', -1), -1, TXPROF_MAX - 1);
JSONToModeTable(SliceObj.Find('tx_prof_modes'),
P.Pans[id].Slices[n].TXProfByMode);
end;
end;
end;