mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 20:37:33 +00:00
feat(filter): произвольные кромки фильтра — таблица по режиму, редактор, drag, TX-полоска
Фильтр главного приёмника перестал быть одним числом. Теперь это таблица (Name, Low, High) на режим: 10 пресетов + VAR1/VAR2, знаковые кромки (у LSB своя строка, у USB своя), паритет с Thetis FilterForm. Слайсы независимые кромки умели и раньше — главный приёмник настраивался только пресетами. Модель (Settings/RadioController): - TFilterSlot/TFilterSet/TFilterTable + заводские дефолты из прежних FILT_*_BW и FILT_*_NAMES (переехали в Settings — из них строится таблица). - FFilterLo/FFilterHi = истина, FFilterBW производная (FilterBWFromEdges), поэтому все прежние читатели ширины (web, зум, band-cache, CAT) не тронуты. - ApplyFilterSlot — единственная точка «слот → состояние тракта». - SetFilterEdges: произвольные кромки уходят в VAR1 с автопереключением, пресеты нельзя испортить случайным движением мыши или энкодера. - Персист: секция "filters" под MAC, пишутся только режимы, отличные от заводских; секция режима сносится целиком, иначе возврат слота к заводскому оставлял бы старый ключ. - Диапазон помнит только выбранный слот: кромки живут в таблице, второго источника истины у VAR нет. UI: - 12 кнопок фильтра (2 ряда по 6), подписи из живой таблицы. - FilterPopup — поповер редактора по ПКМ на кнопке фильтра, по образцу PureSignalPopup (дочерний контрол формы, не top-level: Wayland). Слева слоты, справа Name/Low/High/Width, внизу форма полосы на знаковой оси. - Перетаскивание края полосы на спектре: ±5px, снап 10 Гц, Alt = симметрично (не Ctrl — он создаёт слайс). Отказ на передаче и когда полоса на экране уже 12px, иначе захват съедал бы клик-тюн. Отрисовка: - Полоса главного VFO рисуется по РЕАЛЬНЫМ кромкам приёмника, слайса — по его кромкам; вывод из режима и ширины остался фолбэком. Пара→ширина→пара было преобразованием с потерями: несимметричный фильтр из слайс-CAT (ZZFL/ZZFH) рисовался не там, где звучал. - На передаче полоса считается по TX-фильтру (TXSignedEdges), а не по приёмной ширине — TX-полоса от RX не зависит (модель Thetis). - CalcFilterBandXFor больше не держит свою копию таблицы знака боковой: единственная таблица теперь FilterEdgesFromBW. Управление: - CAT ZZFL/ZZFH заработали на основном порту (были заглушками). - Андромеда: энкодеры #4/#5 — независимые Filter High/Low, как на железе G2 (было: #4 ширина, #5 не реализован). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+218
@@ -89,12 +89,52 @@ const
|
||||
FILT_DMR_BW = 12500;
|
||||
DMR_FM_DEV = 2500;
|
||||
|
||||
// UI-подписи пресетов. Раньше жили в MainForm — переехали сюда, потому что
|
||||
// из них строится заводская таблица фильтров (DefaultFilterSet).
|
||||
FILT_SSB_NAMES: array[0..FILT_COUNT-1] of string =
|
||||
('5.0k','4.4k','3.8k','3.3k','2.9k','2.7k','2.4k','2.1k','1.8k','1.0k');
|
||||
FILT_CW_NAMES: array[0..FILT_COUNT-1] of string =
|
||||
('1.0k','800','600','500','400','250','150','100','50','25');
|
||||
FILT_DSB_NAMES: array[0..FILT_COUNT-1] of string =
|
||||
('16k','12k','10k','8.0k','6.6k','5.2k','4.0k','3.1k','2.9k','2.4k');
|
||||
FILT_AM_NAMES: array[0..FILT_COUNT-1] of string =
|
||||
('20k','18k','16k','12k','10k','9.0k','8.0k','7.0k','6.0k','5.0k');
|
||||
FILT_DIGI_NAMES: array[0..FILT_COUNT-1] of string =
|
||||
('3.0k','2.7k','2.4k','2.1k','1.8k','1.5k','1.2k','900','600','300');
|
||||
FILT_FM_NAMES: array[0..1] of string = ('NFM', 'FM');
|
||||
FILT_WFM_NAMES: array[0..FILT_WFM_COUNT-1] of string = ('200k', '160k', '120k');
|
||||
FILT_RAW_NAMES: array[0..FILT_RAW_COUNT-1] of string =
|
||||
('6.25k', '10k', '12.5k', '20k', '25k');
|
||||
|
||||
// --- Редактируемая таблица фильтров -------------------------------------
|
||||
// 10 пресетов + VAR1/VAR2 на режим (паритет с Thetis FilterForm). Кромки
|
||||
// ЗНАКОВЫЕ и хранятся ПО РЕЖИМУ: у LSB своя строка (-2800..-100), у USB
|
||||
// своя — поэтому при применении фильтра конвертировать боковую больше не
|
||||
// нужно, и таблица знака в проекте остаётся ровно одна (FilterEdgesFromBW,
|
||||
// ниже) — только для постройки заводских дефолтов.
|
||||
FILT_VAR1 = FILT_COUNT; // 10
|
||||
FILT_VAR2 = FILT_COUNT + 1; // 11
|
||||
FILT_SLOTS = FILT_COUNT + 2; // 12 — сетка кнопок 2 ряда по 6
|
||||
// Минимальная ширина полосы: ниже неё фильтр звенит, а перетаскивание края
|
||||
// мышью легко схлопнуло бы полосу в ноль.
|
||||
FILT_MIN_WIDTH = 50;
|
||||
|
||||
type
|
||||
TFilterSlot = record
|
||||
Name: string;
|
||||
Lo, Hi: Integer; // знаковые кромки в Гц относительно несущей
|
||||
end;
|
||||
TFilterSet = array[0..FILT_SLOTS-1] of TFilterSlot; // на режим
|
||||
TFilterTable = array[0..CFG_MODE_MAX] of TFilterSet; // по режиму
|
||||
|
||||
TBandSettings = record
|
||||
VfoA: Double;
|
||||
VfoB: Double;
|
||||
Mode: Integer;
|
||||
FilterIdx: Integer;
|
||||
// FilterIdx — слот в таблице фильтров режима (см. TFilterSet); сами
|
||||
// кромки живут в таблице, а не здесь, поэтому диапазон помнит только
|
||||
// выбор. FilterBW остался легаси-полем для чтения старых конфигов.
|
||||
FilterBW: Integer;
|
||||
AGCMode: Integer;
|
||||
AGCTop: Integer;
|
||||
@@ -581,10 +621,120 @@ type
|
||||
// Тема — глобальная настройка, не привязана к устройству
|
||||
procedure SaveTheme(ALightTheme: Boolean);
|
||||
function LoadTheme: Boolean;
|
||||
// Таблица фильтров per-device (JSON-секция "filters" под MAC). Пишутся
|
||||
// только режимы, отличающиеся от заводских, — конфиг не пухнет.
|
||||
function LoadFilters(const MAC: array of Byte; out T: TFilterTable): Boolean;
|
||||
procedure SaveFilters(const MAC: array of Byte; const T: TFilterTable);
|
||||
end;
|
||||
|
||||
// Сколько пресетов реально есть у режима (VAR1/VAR2 идут сверх этого числа).
|
||||
function FilterPresetCount(Mode: Integer): Integer;
|
||||
// Заводской индекс фильтра режима.
|
||||
function FilterDefIdx(Mode: Integer): Integer;
|
||||
// Единственная в проекте таблица «магнитуда полосы → знаковые кромки».
|
||||
// Нужна только для постройки заводских дефолтов и для чтения старых
|
||||
// конфигов, где хранилась одна ширина.
|
||||
procedure FilterEdgesFromBW(Mode, BW: Integer; out Lo, Hi: Integer);
|
||||
// Обратное преобразование для легаси-читателей FFilterBW. У SSB/DIGI «полоса»
|
||||
// исторически = внешняя кромка (2.7k при кромках 100..2700), у остальных
|
||||
// режимов — полная ширина; здесь это воспроизведено один в один.
|
||||
function FilterBWFromEdges(Mode, Lo, Hi: Integer): Integer;
|
||||
function DefaultFilterSet(Mode: Integer): TFilterSet;
|
||||
procedure DefaultFilterTable(out T: TFilterTable);
|
||||
|
||||
implementation
|
||||
|
||||
function FilterPresetCount(Mode: Integer): Integer;
|
||||
begin
|
||||
case Mode of
|
||||
MODE_FM: Result := FILT_FM_COUNT;
|
||||
MODE_WFM: Result := FILT_WFM_COUNT;
|
||||
MODE_DMR: Result := 1;
|
||||
MODE_FMRAW: Result := FILT_RAW_COUNT;
|
||||
else Result := FILT_COUNT;
|
||||
end;
|
||||
end;
|
||||
|
||||
function FilterDefIdx(Mode: Integer): Integer;
|
||||
begin
|
||||
case Mode of
|
||||
MODE_LSB, MODE_USB: Result := FILT_SSB_DEF;
|
||||
MODE_DIGL, MODE_DIGU: Result := FILT_DIGI_DEF;
|
||||
MODE_DSB: Result := FILT_DSB_DEF;
|
||||
MODE_CWL, MODE_CWU: Result := FILT_CW_DEF;
|
||||
MODE_FM: Result := FILT_FM_DEF;
|
||||
MODE_WFM: Result := FILT_WFM_DEF;
|
||||
MODE_DMR: Result := 0;
|
||||
MODE_FMRAW: Result := FILT_RAW_DEF;
|
||||
else Result := FILT_AM_DEF;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure FilterEdgesFromBW(Mode, BW: Integer; out Lo, Hi: Integer);
|
||||
var Half: Integer;
|
||||
begin
|
||||
Half := BW div 2;
|
||||
case Mode of
|
||||
MODE_LSB: begin Lo := -BW; Hi := -100; end;
|
||||
MODE_USB: begin Lo := 100; Hi := BW; end;
|
||||
// DIGI: полоса считается от нуля — поднесущие цифровых видов начинаются
|
||||
// низко, срез на 100 Гц как в SSB съел бы их.
|
||||
MODE_DIGL: begin Lo := -BW; Hi := 0; end;
|
||||
MODE_DIGU: begin Lo := 0; Hi := BW; end;
|
||||
else Lo := -Half; Hi := Half; // DSB/CW/FM/AM/SAM/WFM
|
||||
end;
|
||||
end;
|
||||
|
||||
function FilterBWFromEdges(Mode, Lo, Hi: Integer): Integer;
|
||||
begin
|
||||
case Mode of
|
||||
MODE_LSB, MODE_USB, MODE_DIGL, MODE_DIGU:
|
||||
Result := Max(Abs(Lo), Abs(Hi));
|
||||
else
|
||||
Result := Hi - Lo;
|
||||
end;
|
||||
end;
|
||||
|
||||
function DefaultFilterSet(Mode: Integer): TFilterSet;
|
||||
var i, n, bw: Integer;
|
||||
begin
|
||||
n := FilterPresetCount(Mode);
|
||||
for i := 0 to FILT_SLOTS - 1 do
|
||||
begin
|
||||
Result[i].Name := '';
|
||||
Result[i].Lo := 0;
|
||||
Result[i].Hi := 0;
|
||||
end;
|
||||
bw := 0;
|
||||
for i := 0 to n - 1 do
|
||||
begin
|
||||
case Mode of
|
||||
MODE_LSB, MODE_USB: begin bw := FILT_SSB_BW[i]; Result[i].Name := FILT_SSB_NAMES[i]; end;
|
||||
MODE_DIGL, MODE_DIGU: begin bw := FILT_DIGI_BW[i]; Result[i].Name := FILT_DIGI_NAMES[i]; end;
|
||||
MODE_DSB: begin bw := FILT_DSB_BW[i]; Result[i].Name := FILT_DSB_NAMES[i]; end;
|
||||
MODE_CWL, MODE_CWU: begin bw := FILT_CW_BW[i]; Result[i].Name := FILT_CW_NAMES[i]; end;
|
||||
MODE_FM: begin bw := FILT_FM_BW[i]; Result[i].Name := FILT_FM_NAMES[i]; end;
|
||||
MODE_WFM: begin bw := FILT_WFM_BW[i]; Result[i].Name := FILT_WFM_NAMES[i]; end;
|
||||
MODE_DMR: begin bw := FILT_DMR_BW; Result[i].Name := '12.5k'; end;
|
||||
MODE_FMRAW: begin bw := FILT_RAW_BW[i]; Result[i].Name := FILT_RAW_NAMES[i]; end;
|
||||
else begin bw := FILT_AM_BW[i]; Result[i].Name := FILT_AM_NAMES[i]; end;
|
||||
end;
|
||||
FilterEdgesFromBW(Mode, bw, Result[i].Lo, Result[i].Hi);
|
||||
end;
|
||||
// VAR1/VAR2 стартуют копией заводского пресета режима — «чистый лист»,
|
||||
// который не сдвигает звук, пока его не тронули.
|
||||
Result[FILT_VAR1] := Result[FilterDefIdx(Mode)];
|
||||
Result[FILT_VAR1].Name := 'VAR1';
|
||||
Result[FILT_VAR2] := Result[FilterDefIdx(Mode)];
|
||||
Result[FILT_VAR2].Name := 'VAR2';
|
||||
end;
|
||||
|
||||
procedure DefaultFilterTable(out T: TFilterTable);
|
||||
var m: Integer;
|
||||
begin
|
||||
for m := 0 to CFG_MODE_MAX do T[m] := DefaultFilterSet(m);
|
||||
end;
|
||||
|
||||
function LegacyAverageTimeMS(FPS, AvgMode, AvgCount: Integer;
|
||||
Backmult, DefaultMS: Double): Double;
|
||||
var
|
||||
@@ -1680,6 +1830,74 @@ begin
|
||||
end;
|
||||
end;
|
||||
|
||||
function TSettingsManager.LoadFilters(const MAC: array of Byte;
|
||||
out T: TFilterTable): Boolean;
|
||||
var
|
||||
MacStr, K: string;
|
||||
DevObj, Root, Obj: TJSONObject;
|
||||
D: TJSONData;
|
||||
m, s: Integer;
|
||||
begin
|
||||
DefaultFilterTable(T);
|
||||
MacStr := MacToStr(MAC);
|
||||
Result := FRoot.Find(MacStr) <> nil;
|
||||
if not Result then Exit;
|
||||
DevObj := GetDevObj(MacStr);
|
||||
if DevObj.Find('filters') = nil then begin Result := False; Exit; end;
|
||||
Root := EnsureObj(DevObj, 'filters');
|
||||
for m := 0 to CFG_MODE_MAX do
|
||||
begin
|
||||
D := Root.Find('m' + IntToStr(m));
|
||||
if (D = nil) or not (D is TJSONObject) then Continue;
|
||||
Obj := TJSONObject(D);
|
||||
for s := 0 to FILT_SLOTS - 1 do
|
||||
begin
|
||||
K := 's' + IntToStr(s) + '_';
|
||||
T[m][s].Name := JS(Obj, K + 'n', T[m][s].Name);
|
||||
T[m][s].Lo := JI(Obj, K + 'lo', T[m][s].Lo);
|
||||
T[m][s].Hi := JI(Obj, K + 'hi', T[m][s].Hi);
|
||||
if T[m][s].Hi <= T[m][s].Lo then // битый конфиг не должен глушить приём
|
||||
begin
|
||||
T[m][s].Lo := DefaultFilterSet(m)[s].Lo;
|
||||
T[m][s].Hi := DefaultFilterSet(m)[s].Hi;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
procedure TSettingsManager.SaveFilters(const MAC: array of Byte;
|
||||
const T: TFilterTable);
|
||||
var
|
||||
K: string;
|
||||
Root, Obj: TJSONObject;
|
||||
Def: TFilterSet;
|
||||
m, s, Idx: Integer;
|
||||
Differs: Boolean;
|
||||
begin
|
||||
Root := EnsureObj(GetDevObj(MacToStr(MAC)), 'filters');
|
||||
for m := 0 to CFG_MODE_MAX do
|
||||
begin
|
||||
Def := DefaultFilterSet(m);
|
||||
Differs := False;
|
||||
for s := 0 to FILT_SLOTS - 1 do
|
||||
if (T[m][s].Lo <> Def[s].Lo) or (T[m][s].Hi <> Def[s].Hi) or
|
||||
(T[m][s].Name <> Def[s].Name) then Differs := True;
|
||||
// Секцию режима всегда сносим целиком: иначе возврат слота к заводскому
|
||||
// оставил бы старый ключ и он всплыл бы при следующей загрузке.
|
||||
Idx := Root.IndexOfName('m' + IntToStr(m));
|
||||
if Idx >= 0 then Root.Delete(Idx);
|
||||
if not Differs then Continue;
|
||||
Obj := EnsureObj(Root, 'm' + IntToStr(m));
|
||||
for s := 0 to FILT_SLOTS - 1 do
|
||||
begin
|
||||
K := 's' + IntToStr(s) + '_';
|
||||
JWS(Obj, K + 'n', T[m][s].Name);
|
||||
JW (Obj, K + 'lo', T[m][s].Lo);
|
||||
JW (Obj, K + 'hi', T[m][s].Hi);
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
|
||||
class procedure TSettingsManager.DefaultAnt936x(out A: TAD936xAnt);
|
||||
var i: Integer;
|
||||
begin
|
||||
|
||||
Reference in New Issue
Block a user