From cd816c54d65558549a469d762b980d5b3c638ad3 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Thu, 16 Jul 2026 18:58:28 +0300 Subject: [PATCH] feat(fm): add universal FM RAW decoder output --- AndromedaPanel.pas | 5 +- CATAdapter.pas | 2 +- CATEngine.pas | 28 +++---- ChannelStore.pas | 13 ++-- ChannelsForm.pas | 10 +-- MainForm.pas | 144 ++++++++++++++++++++++++++++++---- RadioController.pas | 98 ++++++++++++++++++------ RadioModes.pas | 30 ++++++++ Settings.pas | 33 ++++++-- SpectrumView.pas | 170 +++++++++++++++++++++++++++++++++++++++-- SpectrumViewOpengl.pas | 79 +++++++++++++++++++ VfoOverlay.pas | 68 ++++++++++------- WDSPEngine.pas | 85 +++++++++++++++------ WebAdapter.pas | 2 +- WebPageHtml.pas | 27 ++++--- WebServer.pas | 10 +-- 16 files changed, 656 insertions(+), 148 deletions(-) create mode 100644 RadioModes.pas diff --git a/AndromedaPanel.pas b/AndromedaPanel.pas index f90382c..73066ad 100644 --- a/AndromedaPanel.pas +++ b/AndromedaPanel.pas @@ -24,12 +24,13 @@ unit AndromedaPanel; interface uses - SysUtils, RadioController, AndromedaMap; + SysUtils, RadioController, AndromedaMap, RadioModes; const AND_VFO_STEP_HZ = 10; // Гц на один шаг энкодера VFO (как DoTuneUp/Down в CAT) AND_BW_STEP_HZ = 50; // Гц на шаг энкодера ширины фильтра - MODE_COUNT_CYCLE = 8; // MODE_LSB..MODE_SAM (0..7) для перебора режимов + // Аппаратная панель намеренно перебирает только LSB..SAM. + MODE_COUNT_CYCLE = RadioModes.MODE_SAM - RadioModes.MODE_LSB + 1; type TAndromedaSend = procedure(const S: string) of object; diff --git a/CATAdapter.pas b/CATAdapter.pas index ca6ed52..7682c29 100644 --- a/CATAdapter.pas +++ b/CATAdapter.pas @@ -307,7 +307,7 @@ begin FSyncInt := V; FController.Invoke(@SyncMode); end; procedure TCATAdapter.SyncMode; begin - if (FSyncInt < 0) or (FSyncInt > MODE_DMR) then Exit; + if (FSyncInt < MODE_MIN) or (FSyncInt > MODE_MAX) then Exit; FController.SetMode(FSyncInt); end; diff --git a/CATEngine.pas b/CATEngine.pas index 7b9fc44..2a1ccbe 100644 --- a/CATEngine.pas +++ b/CATEngine.pas @@ -24,19 +24,14 @@ unit CATEngine; interface uses - Classes, SysUtils, Math, SyncObjs; + Classes, SysUtils, Math, SyncObjs, RadioModes; const CAT_ERROR = '?;'; CAT_EXEC_ERROR = 'E;'; CAT_ID_NUM = '019'; // TS-2000 compatible - // Mode mapping ewsdr → CAT (зеркало MODE_* из WDSPEngine) - MODE_LSB = 0; MODE_USB = 1; MODE_DSB = 2; - MODE_CWL = 3; MODE_CWU = 4; MODE_FM = 5; - MODE_AM = 6; MODE_SAM = 7; - MODE_DIGU = 8; MODE_DIGL = 9; MODE_WFM = 10; MODE_DMR = 11; - CAT_MODE_COUNT = 12; + CAT_MODE_COUNT = RadioModes.MODE_COUNT; // FM repeater offset directions — зеркало FMRepeater.RPT_* (значения должны // совпадать: контроллер хранит FFMRptDir в этих же кодах). @@ -392,6 +387,7 @@ begin MODE_FM: Result := 4; MODE_WFM: Result := 4; // treated as FM MODE_DMR: Result := 4; // DMR RF transport is narrow FM + MODE_FMRAW: Result := 4; // raw discriminator is still FM transport MODE_AM: Result := 5; MODE_SAM: Result := 5; // treated as AM MODE_DIGU: Result := 6; // Kenwood FSK @@ -1793,20 +1789,24 @@ end; function TCATEngine.ZZML: string; // ZZML: mode list (returns all mode names) -begin Result := 'LSB USB DSB CWL CWU FM AM SAM DIGUDIGLWFM DMR '; end; +begin Result := 'LSB USB DSB CWL CWU FM AM SAM DIGUDIGLWFM DMR FMRAW'; end; function TCATEngine.ZZMN(const s: string): string; // ZZMN: mode name — returns mode name for given mode number. -// Индекс режима теперь двузначный (0..10), но односимвольный запрос +// Индекс режима двузначный (MODE_MIN..MODE_MAX), но односимвольный запрос // продолжаем принимать — старые клиенты шлют одну цифру. var k: Integer; names: array[0..CAT_MODE_COUNT-1] of string; begin - names[0]:='LSB'; names[1]:='USB'; names[2]:='DSB'; names[3]:='CWL'; - names[4]:='CWU'; names[5]:='FM'; names[6]:='AM'; names[7]:='SAM'; - names[8]:='DIGU'; names[9]:='DIGL'; names[10]:='WFM'; names[11]:='DMR'; + names[MODE_LSB]:='LSB'; names[MODE_USB]:='USB'; + names[MODE_DSB]:='DSB'; names[MODE_CWL]:='CWL'; + names[MODE_CWU]:='CWU'; names[MODE_FM]:='FM'; + names[MODE_AM]:='AM'; names[MODE_SAM]:='SAM'; + names[MODE_DIGU]:='DIGU'; names[MODE_DIGL]:='DIGL'; + names[MODE_WFM]:='WFM'; names[MODE_DMR]:='DMR'; + names[MODE_FMRAW]:='FM RAW'; if (Length(s) = 1) or (Length(s) = 2) then begin - k := EnsureRange(StrToIntDef(s,0),0,CAT_MODE_COUNT-1); + k := EnsureRange(StrToIntDef(s, MODE_MIN), MODE_MIN, MODE_MAX); Result := names[k]; end else Result := CAT_ERROR; end; @@ -1819,7 +1819,7 @@ begin end; function TCATEngine.ZZMV: string; -// ZZMV: mode value — returns current mode index (ewsdr 0-7) +// ZZMV: mode value — returns current ewsdr mode index (MODE_MIN..MODE_MAX) begin Result := Pad(SafeGetMode, 2); end; function TCATEngine.ZZMY: string; diff --git a/ChannelStore.pas b/ChannelStore.pas index 09c5db9..cd8dd61 100644 --- a/ChannelStore.pas +++ b/ChannelStore.pas @@ -8,11 +8,13 @@ unit ChannelStore; interface uses - SysUtils, Classes, Math, fpJSON, jsonparser, jsonscanner, PlatformUtils; + SysUtils, Classes, Math, fpJSON, jsonparser, jsonscanner, PlatformUtils, + RadioModes; const CHANNELS_FILE = 'channels.json'; - CHANNEL_MODE_MAX = 11; // LSB..DMR + CHANNEL_MODE_MIN = RadioModes.MODE_MIN; + CHANNEL_MODE_MAX = RadioModes.MODE_MAX; type TChannel = record @@ -20,7 +22,7 @@ type Name: string; RXFreq: Double; // Hz TXFreq: Double; // Hz - Mode: Integer; // 0=LSB ... 10=WFM 11=DMR + Mode: Integer; // RadioModes.MODE_* persisted index RptDir: Integer; // 0=none 1=minus 2=plus RptOffsetHz: Double; // Hz CTCSSOn: Boolean; @@ -59,7 +61,7 @@ begin Ch.Name := 'New Channel'; Ch.RXFreq := 145500000.0; Ch.TXFreq := 145500000.0; - Ch.Mode := 5; // FM + Ch.Mode := MODE_FM; Ch.RptDir := 0; Ch.RptOffsetHz := 600000.0; Ch.CTCSSOn := False; @@ -207,7 +209,8 @@ begin Ch.Name := JS(O, 'name', 'Channel'); Ch.RXFreq := JD(O, 'rx_freq', 145500000.0); Ch.TXFreq := JD(O, 'tx_freq', 145500000.0); - Ch.Mode := EnsureRange(JI(O, 'mode', 5), 0, CHANNEL_MODE_MAX); + Ch.Mode := EnsureRange(JI(O, 'mode', MODE_FM), + CHANNEL_MODE_MIN, CHANNEL_MODE_MAX); Ch.RptDir := EnsureRange(JI(O, 'rpt_dir', 0), 0, 2); Ch.RptOffsetHz:= JD(O, 'rpt_offset', 600000.0); Ch.CTCSSOn := JB(O, 'ctcss_on', False); diff --git a/ChannelsForm.pas b/ChannelsForm.pas index 2293564..5d84547 100644 --- a/ChannelsForm.pas +++ b/ChannelsForm.pas @@ -14,13 +14,13 @@ uses Forms, Controls, Graphics, StdCtrls, ExtCtrls, FlatButton, FlatCheckBox, FlatComboBox, FlatEdit, FlatSpinEdit, FlatFloatSpinEdit, FlatListBox, - AppTheme, ChannelStore; + AppTheme, ChannelStore, RadioModes; const // Копии из MainForm: нужны для выпадающих списков - CH_MODE_COUNT = 12; + CH_MODE_COUNT = RadioModes.MODE_COUNT; CH_MODE_NAMES: array[0..CH_MODE_COUNT-1] of string = - ('LSB','USB','DSB','CWL','CWU','FM','AM','SAM','DIGU','DIGL','WFM','DMR'); + ('LSB','USB','DSB','CWL','CWU','FM','AM','SAM','DIGU','DIGL','WFM','DMR','FM RAW'); CH_CTCSS_COUNT = 38; CH_CTCSS_NAMES: array[0..CH_CTCSS_COUNT-1] of string = ( @@ -459,7 +459,7 @@ begin FEdName.Text := Ch.Name; FSpinRXFreq.Value := Ch.RXFreq / 1000000.0; FSpinTXFreq.Value := Ch.TXFreq / 1000000.0; - FCmbMode.ItemIndex := EnsureRange(Ch.Mode, 0, CH_MODE_COUNT - 1); + FCmbMode.ItemIndex := EnsureRange(Ch.Mode, MODE_MIN, MODE_MAX); StyleBtn(FBtnRptNone, Ch.RptDir = 0); StyleBtn(FBtnRptMinus, Ch.RptDir = 1); StyleBtn(FBtnRptPlus, Ch.RptDir = 2); @@ -487,7 +487,7 @@ begin Ch.Name := FEdName.Text; Ch.RXFreq := FSpinRXFreq.Value * 1000000.0; Ch.TXFreq := FSpinTXFreq.Value * 1000000.0; - Ch.Mode := EnsureRange(FCmbMode.ItemIndex, 0, CH_MODE_COUNT - 1); + Ch.Mode := EnsureRange(FCmbMode.ItemIndex, MODE_MIN, MODE_MAX); // RptDir берётся из кнопок (Active) if FBtnRptMinus.Active then Ch.RptDir := 1 else if FBtnRptPlus.Active then Ch.RptDir := 2 diff --git a/MainForm.pas b/MainForm.pas index 76f1f6e..fac98c9 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -37,6 +37,7 @@ uses WidebandView, StatusBar, PlatformUtils, + RadioModes, WinFirewall, BoardUtils, WisdomBuilder, UISync, FlatEdit, FMRepeater, @@ -67,7 +68,6 @@ const WDSP_WISDOM_FILE = 'wdspWisdom00'; BAND_COUNT = 12; // макс. слотов среди планов (Pluto VHF/UHF = 12, HF = 11) - MODE_COUNT = 12; // Полосы/дефолты фильтров (FILT_COUNT, FILT_*_BW, FILT_*_DEF, FILT_FM_*) // перенесены в Settings.pas — нужны TRadioController. Здесь остаются только // UI-подписи кнопок (NAMES). @@ -83,6 +83,8 @@ const ('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'); // CTCSS tones (38 standard tones, no None — toggle via CTCSS button) // CTCSS_COUNT и CTCSS_TONES перенесены в FMRepeater (нужны TRadioController). @@ -105,8 +107,8 @@ const '160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m',''); MODE_NAMES: array[0..MODE_COUNT-1] of string = ( - 'LSB','USB','DSB','CWL','CWU','FM','AM','SAM','DIGU','DIGL','WFM','DMR'); - MODE_ROWS = 3; // 4 колонки; последний ряд неполный + 'LSB','USB','DSB','CWL','CWU','FM','AM','SAM','DIGU','DIGL','WFM','DMR','FMRAW'); + MODE_ROWS = 4; // 4 колонки; последний ряд неполный SMETER_WIDTH_RATIO = 0.32; SMETER_MARGIN = 3; @@ -299,6 +301,9 @@ type BtnFMSQ: TFlatButton; TrkFMSQ: TFlatSlider; LblFMSQ: TLabel; + PanelRawDev: TPanel; + TrkRawDev: TFlatSlider; + LblRawDev: TLabel; PanelFMCTCSS: TPanel; BtnFMCTCSS: TFlatButton; BtnFMCTCSSTone: TFlatButton; @@ -470,9 +475,11 @@ type procedure Btn2TonClick(Sender: TObject); procedure OnPSPopupChanged(Sender: TObject); // персист PS-параметров procedure UpdatePSButtons; // стиль/доступность PS/2TON + procedure UpdateIMDMarkers; // 2TON: маркеры пиков/IMD3 на спектре procedure UpdateFilterButtons; procedure BtnFMSQClick(Sender: TObject); procedure TrkFMSQChange(Sender: TObject); + procedure TrkRawDevChange(Sender: TObject); procedure ApplyFMSquelch; procedure BtnFMCTCSSClick(Sender: TObject); procedure BtnFMCTCSSToneClick(Sender: TObject); @@ -930,7 +937,7 @@ begin FController.FVfoA := 14200000; FController.FVfoB := 7100000; FController.FActiveVfo := 0; - FController.FMode := 1; + FController.FMode := MODE_USB; FController.FFilter := 5; FController.FAGCMode := 1; // MEDIUM FController.FAGCTop := 90; // −90 dBm @@ -1376,6 +1383,7 @@ begin ApplySpecViewGridFromState; // сетка спектра под активный тракт RefreshTxIndicators; // TX-бейджи флагов (какой слайс передаёт) UpdateTXControlAvailability; + UpdateIMDMarkers; // 2TON: маркеры IMD на TX-спектре end; // (DUC-очередь сбрасывает SetMOX) rfTuning: StyleButton(BtnTUN, FController.FTuning); @@ -1484,6 +1492,12 @@ begin for i := 0 to FILT_COUNT - 1 do StyleButton(BtnFilter[i], i = FController.FFilter); SyncSpecViewFreq; end; + rfFMDeviation: + if (TrkRawDev <> nil) and (LblRawDev <> nil) then + begin + TrkRawDev.Position := Round(FController.FRawFMDeviation) div 50; + LblRawDev.Caption := Format('%.2f k', [FController.FRawFMDeviation / 1000.0]); + end; rfAGCMode: for i := 0 to 4 do StyleButton(BtnAGCMode[i], i = FController.FAGCMode); rfVolume: @@ -1524,6 +1538,7 @@ begin // подсвечивал → визуал расходился с состоянием. if BtnDUP <> nil then StyleButton(BtnDUP, FController.FDisplayDuplex); UpdateRxMuteButton; // активность RX MUTE зависит от DUP + UpdateIMDMarkers; // IMD-маркеры 2TON живут только в DUP // На передаче переключаем источник пикселей и сбрасываем буферы, чтобы // старые TX/RX bin'ы не оставались на следующем кадре. if FController.FTransmitting then @@ -1550,7 +1565,7 @@ begin begin if FStepDropDown <> nil then FStepDropDown.SetItemIndex(FController.FFMStepIdx); if FWebServer <> nil then FWebServer.FMStepIdx := FController.FFMStepIdx; - if (FController.FMode in [MODE_FM, MODE_DMR]) and + if (FController.FMode in [MODE_FM, MODE_DMR, MODE_FMRAW]) and (FSpecView <> nil) then FSpecView.FMGridStepHz := FM_STEP_HZ[FController.FFMStepIdx]; end; @@ -2230,6 +2245,35 @@ begin Inc(Y, 76); + // FM RAW discriminator deviation. Slider units are 50 Hz: fine enough for + // decoder calibration, while the full 0.5..10 kHz range remains usable. + PanelRawDev := TPanel.Create(Self); + PanelRawDev.Parent := PanelLeft; + PanelRawDev.SetBounds(0, Y, LEFT_W, 50); + PanelRawDev.BevelOuter := bvNone; + PanelRawDev.Visible := False; + MakeLbl(PanelRawDev, 'DEVIATION', 4, 2); + TrkRawDev := TFlatSlider.Create(Self); + TrkRawDev.Parent := PanelRawDev; + TrkRawDev.Left := 5; + TrkRawDev.Top := 22; + TrkRawDev.Width := LEFT_W - 68; + TrkRawDev.Height := 14; + TrkRawDev.Min := RAW_FM_DEV_MIN div 50; + TrkRawDev.Max := RAW_FM_DEV_MAX div 50; + TrkRawDev.Position := Round(FController.FRawFMDeviation) div 50; + TrkRawDev.OnChange := TrkRawDevChange; + LblRawDev := TLabel.Create(Self); + LblRawDev.Parent := PanelRawDev; + LblRawDev.Left := LEFT_W - 61; + LblRawDev.Top := 20; + LblRawDev.Width := 57; + LblRawDev.Alignment := taRightJustify; + LblRawDev.Caption := Format('%.2f k', [FController.FRawFMDeviation / 1000.0]); + LblRawDev.Font.Color := CLR_TEXT; + LblRawDev.Font.Name := 'Courier New'; + LblRawDev.Font.Size := 8; + // FM Squelch panel (visible only in FM mode, above CTCSS) PanelFMSQ := TPanel.Create(Self); PanelFMSQ.Parent := PanelLeft; @@ -2887,6 +2931,7 @@ begin DP(PanelVfoA); DP(PanelVfoB); DP(PanelVfoButtons); DP(PanelBands); DP(PanelMode); DP(PanelFilter); if PanelFMSQ <> nil then DP(PanelFMSQ); + if PanelRawDev <> nil then DP(PanelRawDev); if PanelFMCTCSS <> nil then DP(PanelFMCTCSS); if PanelFMStep <> nil then DP(PanelFMStep); if PanelFMRpt <> nil then DP(PanelFMRpt); @@ -3262,7 +3307,7 @@ begin FSpecView.SpanHz := ViewSpan; FSpecView.Mode := FController.FMode; FSpecView.FilterBW := FController.FFilterBW; - if FController.FMode in [MODE_FM, MODE_DMR] then + if FController.FMode in [MODE_FM, MODE_DMR, MODE_FMRAW] then FSpecView.FMGridStepHz := FM_STEP_HZ[FController.FFMStepIdx] else FSpecView.FMGridStepHz := 0; @@ -3554,7 +3599,7 @@ var Allowed: Boolean; begin if (BtnMOX = nil) or (BtnTUN = nil) or (FController = nil) then Exit; Allowed := FController.FRunning and - (FController.ActiveTXMode <> MODE_DMR); + not (FController.ActiveTXMode in [MODE_DMR, MODE_FMRAW]); BtnMOX.Enabled := Allowed; BtnTUN.Enabled := Allowed; UpdatePSButtons; @@ -3568,7 +3613,7 @@ var begin if (BtnPS = nil) or (Btn2Ton = nil) or (FController = nil) then Exit; TXAllowed := FController.FRunning and - (FController.ActiveTXMode <> MODE_DMR); + not (FController.ActiveTXMode in [MODE_DMR, MODE_FMRAW]); PSAllowed := TXAllowed and FController.PSAvailable; BtnPS.Enabled := PSAllowed; Btn2Ton.Enabled := TXAllowed; @@ -3576,6 +3621,30 @@ begin StyleButton(Btn2Ton, FController.FTwoTone); if (FPSPopup <> nil) and TPureSignalPopup(FPSPopup).Visible then TPureSignalPopup(FPSPopup).RefreshStatus; + UpdateIMDMarkers; +end; + +procedure TMainForm.UpdateIMDMarkers; +// 2TON: маркеры пиков тонов и продуктов IMD3. Только в DUP — дисплей остаётся +// на RX и показывает СВОЙ сигнал после PA (настоящие плечи IMD); цифровой +// TX-спектр продуктов PA не содержит, там замер бессмыслен. Абсолютные +// частоты = TX ± аудио-оффсеты тонов (знак как у генератора: для нижней +// боковой тона лежат НИЖЕ несущей). Замер/отрисовка — в SpectrumView. +var + Sgn: Double; +begin + if (FSpecView = nil) or (FController = nil) then Exit; + if (FController.ActiveTXMode = MODE_LSB) or + (FController.ActiveTXMode = MODE_CWL) or + (FController.ActiveTXMode = MODE_DIGL) then + Sgn := -1.0 + else + Sgn := 1.0; + FSpecView.SetIMDMarkers( + FController.FTwoTone and FController.FTransmitting + and FController.FDisplayDuplex, + ActiveTXFreqHz + Sgn * FController.FTXSettings.PSTTFreq1, + ActiveTXFreqHz + Sgn * FController.FTXSettings.PSTTFreq2); end; procedure TMainForm.UpdateStatusField3; @@ -4268,6 +4337,7 @@ begin EdFMRptOffset.Text := RptFormatOffset(FController.FFMRptOffsetHz); PanelFMRpt.Visible := True; end; + if PanelRawDev <> nil then PanelRawDev.Visible := False; RelayoutBelowBands; Exit; end; @@ -4279,7 +4349,7 @@ begin if PanelFMCTCSS <> nil then PanelFMCTCSS.Visible := False; if PanelFMStep <> nil then begin - if FController.FMode = MODE_DMR then + if FController.FMode in [MODE_DMR, MODE_FMRAW] then begin StyleButton(BtnFMStep, FController.FFMStepOn); if FStepDropDown <> nil then @@ -4293,6 +4363,7 @@ begin end; end; if PanelFMRpt <> nil then PanelFMRpt.Visible := False; + if PanelRawDev <> nil then PanelRawDev.Visible := False; if FController.FMode = MODE_WFM then begin @@ -4315,6 +4386,33 @@ begin Exit; end; + if FController.FMode = MODE_FMRAW then + begin + CloseCTCSSPopup; + if FController.FFilter > FILT_RAW_COUNT - 1 then + FController.FFilter := FILT_RAW_DEF; + FController.FFilterBW := FILT_RAW_BW[FController.FFilter]; + for i := 0 to FILT_RAW_COUNT - 1 do + begin + BtnFilter[i].Caption := FILT_RAW_NAMES[i]; + BtnFilter[i].Left := LeftPanelButtonLeft(PanelFilter.Width, FILT_RAW_COUNT, i); + BtnFilter[i].Width := LeftPanelButtonWidth(PanelFilter.Width, FILT_RAW_COUNT, i); + BtnFilter[i].Top := 16; + BtnFilter[i].Visible := True; + StyleButton(BtnFilter[i], i = FController.FFilter); + end; + for i := FILT_RAW_COUNT to FILT_COUNT - 1 do BtnFilter[i].Visible := False; + PanelFilter.Height := BtnFilter[0].Top + BtnFilter[0].Height + 4; + if PanelRawDev <> nil then + begin + TrkRawDev.Position := Round(FController.FRawFMDeviation) div 50; + LblRawDev.Caption := Format('%.2f k', [FController.FRawFMDeviation / 1000.0]); + PanelRawDev.Visible := True; + end; + RelayoutBelowBands; + Exit; + end; + if FController.FMode = MODE_DMR then begin CloseCTCSSPopup; @@ -4560,13 +4658,15 @@ end; procedure TMainForm.OnPSPopupChanged(Sender: TObject); // Правка PS-параметров в поповере: применение к WDSP делает сам поповер -// (ApplyPSSettingsToDSP), здесь — только персист per-device. +// (ApplyPSSettingsToDSP), здесь — персист per-device + live-обновление +// частот IMD-маркеров (если правили тона при активном 2TON). begin if FController.FDevConnected then begin FController.FSettings.SaveTX(FController.FDevMAC, FController.FTXSettings); FController.FSettings.Save; end; + UpdateIMDMarkers; end; // --------------------------------------------------------------------------- @@ -4587,7 +4687,7 @@ var ViewCenter, ViewSpan: Double; begin if PanelWidth <= 0 then Exit; - if (FController.FMode in [MODE_FM, MODE_DMR]) and + if (FController.FMode in [MODE_FM, MODE_DMR, MODE_FMRAW]) and FController.FFMStepOn then StepHz := FM_STEP_HZ[FController.FFMStepIdx] else @@ -4612,7 +4712,7 @@ begin if (FWidebandView = nil) or (not FWidebandView.TryPixelToFrequency(PixelX, PanelWidth, FreqHz)) then Exit; - if (FController.FMode in [MODE_FM, MODE_DMR]) and + if (FController.FMode in [MODE_FM, MODE_DMR, MODE_FMRAW]) and FController.FFMStepOn then StepHz := FM_STEP_HZ[FController.FFMStepIdx] else @@ -5094,6 +5194,14 @@ begin SaveCurrentBand; end; +procedure TMainForm.TrkRawDevChange(Sender: TObject); +var Hz: Integer; +begin + if FSyncingFromController then Exit; + Hz := TrkRawDev.Position * 50; + FController.SetRawFMDeviation(Hz); +end; + procedure TMainForm.CloseCTCSSPopup; begin if FCTCSSDropDown <> nil then FCTCSSDropDown.ClosePopup; @@ -6183,8 +6291,8 @@ begin Id := P.FirstSliceId; StepHz := 100; if ((Id <> 0) and FController.GetSlice(Id, S) and - (S.Mode = MODE_DMR)) or - ((Id = 0) and (FController.FMode = MODE_DMR)) then + (S.Mode in [MODE_DMR, MODE_FMRAW])) or + ((Id = 0) and (FController.FMode in [MODE_DMR, MODE_FMRAW])) then StepHz := 12500; Hz := Round(VC + (X / W - 0.5) * VS); Hz := ((Hz + StepHz div 2) div StepHz) * StepHz; @@ -7443,6 +7551,10 @@ begin PanelFilter.Top := Y; Y := Y + PanelFilter.Height + 2; end; + if (PanelRawDev <> nil) and PanelRawDev.Visible then + begin + PanelRawDev.Top := Y; Y := Y + PanelRawDev.Height + 2; + end; if (PanelFMStep <> nil) and PanelFMStep.Visible then begin PanelFMStep.Top := Y; Y := Y + PanelFMStep.Height + 2; @@ -7719,7 +7831,7 @@ begin end; if (SliceId <> 0) and FController.GetSlice(SliceId, Sl) then begin - if (Sl.Mode = MODE_DMR) and + if (Sl.Mode in [MODE_DMR, MODE_FMRAW]) and not ((ssCtrl in Shift) or (ssShift in Shift)) then Step := 12500 else if (ssCtrl in Shift) and (ssShift in Shift) then Step := 1000000 else if ssCtrl in Shift then Step := 1000 @@ -7743,7 +7855,7 @@ begin Handled := True; Exit; end; - if (FController.FMode in [MODE_FM, MODE_DMR]) and + if (FController.FMode in [MODE_FM, MODE_DMR, MODE_FMRAW]) and FController.FFMStepOn and not ((ssCtrl in Shift) or (ssShift in Shift)) then Step := FM_STEP_HZ[FController.FFMStepIdx] diff --git a/RadioController.pas b/RadioController.pas index fd49c48..191c014 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -354,6 +354,7 @@ type // ---- FM ---- FFMDeviation: Double; + FRawFMDeviation: Double; FFMCTCSSOn: Boolean; FFMCTCSSToneIdx: Integer; FFMSQOn: Boolean; @@ -563,6 +564,7 @@ type procedure SetMode(M: Integer); procedure SetFilterIdx(Idx: Integer); procedure SetFilterBW(BWHz: Integer); + procedure SetRawFMDeviation(Hz: Integer); procedure SetAGCMode(M: Integer); procedure SetAGCTop(DB: Integer); procedure AGCTopBy(Delta: Integer); @@ -916,7 +918,7 @@ begin FDeviceStore := TDeviceStore.Create; // общий список устройств (desktop+web) // Дефолты (дублируют TMainForm.FormCreate; в GUI перезапишутся, в демоне нужны). FVfoA := 14200000; FVfoB := 7100000; FActiveVfo := 0; - FMode := 1; FFilter := 5; FFilterBW := 2700; + FMode := MODE_USB; FFilter := 5; FFilterBW := 2700; FAGCMode := 1; FAGCTop := 90; FRxGainMode := 2; FRxGainDb := 40; // Pluto: slow_attack по умолчанию FRxMuteOnTx := True; // QO-100 self-monitor выкл по умолчанию (RX заглушён на TX) @@ -934,6 +936,7 @@ begin FWfPalette := 0; FWfGamma := 1.0; FDisplayPixels := 4096; FWfSpeedDiv := 1; FSpecMSAA := 4; FFMStepOn := True; + FRawFMDeviation := RAW_FM_DEV_DEFAULT; FLocalAudio := True; // GUI: звук на локальную карту; демон выключит явно FLastSMeter := -130; FLastSWR := 1; FLastSupplyV := -1; FLastSupplyA := -1; FLastPlutoTempC := TELEMETRY_NONE; FLastPlutoSocTempC := TELEMETRY_NONE; @@ -1316,7 +1319,7 @@ procedure TRadioController.OnAudioReady(const Left, Right: array of Single; begin // Decoded DMR PCM will use this route later. Until then suppress raw 4FSK; // the independent pre-volume tap is handled by OnDemodAudioReady. - if FMode = MODE_DMR then Exit; + if FMode in [MODE_DMR, MODE_FMRAW] then Exit; if FSendAudioToRadio then FNetwork.SendSpeakerAudio(Left, Right, Count); @@ -1332,13 +1335,19 @@ procedure TRadioController.OnDemodAudioReady(const Left, Right: array of Single; Count: Integer); // DSP thread, pre-volume/pre-mute. FeedAudio only copies into a bounded ring. begin - if not Assigned(FDMRDec) then Exit; if FMode = MODE_DMR then begin + if not Assigned(FDMRDec) then Exit; if not FDMRDec.Enabled then FDMRDec.SetEnabled(True); FDMRDec.FeedAudio(Left, Right, Count); end - else if FDMRDec.Enabled then + else if FMode = MODE_FMRAW then + begin + // FM RAW decoder audio deliberately bypasses speaker volume and mute. + if FLocalAudio and Assigned(FAudioOut) then + FAudioOut.Write(Left, Right, Count); + end + else if Assigned(FDMRDec) and FDMRDec.Enabled then FDMRDec.SetEnabled(False); end; @@ -1396,7 +1405,7 @@ begin if not FLocalAudio then Exit; idx := FindSliceIndex(SliceId); if idx < 0 then Exit; - if FSlices[idx].Mode = MODE_DMR then Exit; + if FSlices[idx].Mode in [MODE_DMR, MODE_FMRAW] then Exit; if SliceMutedByTx(idx) then Exit; if FSlices[idx].Audio <> nil then FSlices[idx].Audio.Write(Left, Right, Count); @@ -1417,9 +1426,12 @@ procedure TRadioController.OnSliceDemodAudioReady(SliceId: Integer; var idx: Integer; begin idx := FindSliceIndex(SliceId); - if (idx < 0) or (FSlices[idx].Mode <> MODE_DMR) then Exit; - if Assigned(FSlices[idx].DMR) then + if idx < 0 then Exit; + if (FSlices[idx].Mode = MODE_DMR) and Assigned(FSlices[idx].DMR) then FSlices[idx].DMR.FeedAudio(Left, Right, Count); + if (FSlices[idx].Mode = MODE_FMRAW) and FLocalAudio and + Assigned(FSlices[idx].Audio) then + FSlices[idx].Audio.Write(Left, Right, Count); end; // DMR worker thread: decoded 8 kHz mono -> this slice's 48 kHz local output. @@ -1821,6 +1833,7 @@ var AO: TAudioOutput; begin Result := -1; + Mode := EnsureRange(Mode, MODE_MIN, MODE_MAX); if not Assigned(FDSPEngine) then Exit; if (APanId < 0) or (APanId >= MAX_PANS) then Exit; if (APanId > 0) and (FPanDDCIdx[APanId] < 0) then Exit; // пан не создан @@ -1901,6 +1914,7 @@ end; procedure TRadioController.SetSliceMode(Id, Mode: Integer); var idx, OldMode: Integer; begin + Mode := EnsureRange(Mode, MODE_MIN, MODE_MAX); idx := FindSliceIndex(Id); if idx < 0 then Exit; OldMode := FSlices[idx].Mode; @@ -1910,7 +1924,7 @@ begin if Assigned(FDSPEngine) then FDSPEngine.SetSliceMode(Id, Mode); // DMR is receive-only. If this slice used to be the TX source, hand TX back // to the main receiver as soon as it enters DMR. - if (Mode = MODE_DMR) and (FTxSliceId = Id) then SetTxSlice(0); + if (Mode in [MODE_DMR, MODE_FMRAW]) and (FTxSliceId = Id) then SetTxSlice(0); if (OldMode = MODE_DMR) and (Mode <> MODE_DMR) then FreeAndNil(FSlices[idx].DMR); end; @@ -2221,12 +2235,12 @@ begin if not GetSlice(SliceId, S) then Exit; // EWSDR has no DMR transmitter/AMBE encoder. Do not accidentally use the // analog WFM transmitter for a receive-only DMR slice. - if S.Mode = MODE_DMR then Exit; + if S.Mode in [MODE_DMR, MODE_FMRAW] then Exit; end; if FTxSliceId = SliceId then Exit; // Selecting main A while it is in DMR is allowed as an RX selection, but it // cannot inherit an active carrier from a previously selected TX slice. - if (SliceId = 0) and (FMode = MODE_DMR) then + if (SliceId = 0) and (FMode in [MODE_DMR, MODE_FMRAW]) then begin if FTuning then SetTune(False) else if FTransmitting then SetMOX(False); @@ -2529,11 +2543,15 @@ begin begin FDSPEngine.SetFilter(Lo, Hi); FDSPEngine.SetTXFilter(Lo, Hi); - if (FMode = MODE_FM) or (FMode = MODE_DMR) then + if FMode in [MODE_FM, MODE_DMR, MODE_FMRAW] then begin - FDSPEngine.SetRXFMDeviation(FFMDeviation); - FDSPEngine.SetTXFMParams(FFMDeviation, FTXSettings.FMLowCut, - FTXSettings.FMHighCut, FTXSettings.FMEmphPosition); + if FMode = MODE_FMRAW then + FDSPEngine.SetRawFMDeviation(FRawFMDeviation) + else + FDSPEngine.SetRXFMDeviation(FFMDeviation); + if FMode <> MODE_FMRAW then + FDSPEngine.SetTXFMParams(FFMDeviation, FTXSettings.FMLowCut, + FTXSettings.FMHighCut, FTXSettings.FMEmphPosition); // A squelch would remove sync preambles; DMR always receives open. FDSPEngine.SetFMSquelch((FMode = MODE_FM) and FFMSQOn, FFMSQLevel); end @@ -2565,6 +2583,11 @@ begin FFilterBW := FILT_DMR_BW; FFMDeviation := DMR_FM_DEV; end; + MODE_FMRAW: + begin + if FFilter > FILT_RAW_COUNT - 1 then FFilter := FILT_RAW_DEF; + FFilterBW := FILT_RAW_BW[FFilter]; + end; MODE_LSB, MODE_USB: begin FFilter := FILT_SSB_DEF; FFilterBW := FILT_SSB_BW[FFilter]; end; MODE_DIGU, MODE_DIGL: @@ -2585,6 +2608,7 @@ begin MODE_FM: Result := FILT_FM_BW[EnsureRange(Idx, 0, FILT_FM_COUNT - 1)]; MODE_WFM: Result := FILT_WFM_BW[EnsureRange(Idx, 0, FILT_WFM_COUNT - 1)]; MODE_DMR: Result := FILT_DMR_BW; + MODE_FMRAW: Result := FILT_RAW_BW[EnsureRange(Idx, 0, FILT_RAW_COUNT - 1)]; MODE_LSB, MODE_USB: Result := FILT_SSB_BW[EnsureRange(Idx, 0, FILT_COUNT - 1)]; MODE_DIGU, MODE_DIGL: @@ -2663,6 +2687,11 @@ begin FFilterBW := FILT_DMR_BW; FFMDeviation := DMR_FM_DEV; end; + if FMode = MODE_FMRAW then + begin + if FFilter > FILT_RAW_COUNT - 1 then FFilter := FILT_RAW_DEF; + FFilterBW := FILT_RAW_BW[FFilter]; + end; end; function TRadioController.MakeBandSettings: TBandSettings; @@ -2821,6 +2850,7 @@ begin Result.AudioOutDevice := FAudioOutDevName; Result.AudioInDevice := FAudioInDevName; Result.SendAudioToRadio := FSendAudioToRadio; + Result.RawFMDeviation := Round(FRawFMDeviation); end; procedure TRadioController.SaveGlobalSettings; @@ -2832,7 +2862,7 @@ procedure TRadioController.ApplyBandDSP(const B: TBandSettings); // DSP-настройки диапазона + события рендера. Частоты VFO/центр НЕ трогает. begin // --- Режим --- - FMode := B.Mode; + FMode := EnsureRange(B.Mode, MODE_MIN, MODE_MAX); if Assigned(FDMRDec) then FDMRDec.SetEnabled(FMode = MODE_DMR); if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetMode(FMode); @@ -2867,7 +2897,7 @@ begin end; FFMStepOn := B.FMStepOn; FFMStepIdx := EnsureRange(B.FMStepIdx, 0, 3); - if FMode = MODE_DMR then + if FMode in [MODE_DMR, MODE_FMRAW] then begin FFMStepOn := True; FFMStepIdx := 1; // DMR default raster: 12.5 kHz @@ -2974,8 +3004,8 @@ begin if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetShift(Offset); HalfSpan := FSpanHz / 2; case FMode of - 0: begin FiltLo := Offset - FFilterBW; FiltHi := Offset - 100; end; - 1: begin FiltLo := Offset + 100; FiltHi := Offset + FFilterBW; end; + MODE_LSB: begin FiltLo := Offset - FFilterBW; FiltHi := Offset - 100; end; + MODE_USB: begin FiltLo := Offset + 100; FiltHi := Offset + FFilterBW; end; else begin FiltLo := Offset - FFilterBW/2; FiltHi := Offset + FFilterBW/2; end; end; if (FiltHi > HalfSpan) or (FiltLo < -HalfSpan) then @@ -3228,19 +3258,20 @@ end; procedure TRadioController.SetMode(M: Integer); begin + M := EnsureRange(M, MODE_MIN, MODE_MAX); FMode := M; // DMR channels use a 12.5 kHz raster by default. Keep the ordinary STEP // controls enabled so the operator can select another raster afterwards. - if FMode = MODE_DMR then + if FMode in [MODE_DMR, MODE_FMRAW] then begin FFMStepOn := True; - FFMStepIdx := 1; // 12.5 kHz + if FMode = MODE_DMR then FFMStepIdx := 1; // 12.5 kHz Changed(rfFMStep); Changed(rfFMStepIdx); end; // Entering receive-only DMR on the selected main TX source immediately // releases an existing carrier. SetTune(False) also releases MOX. - if ActiveTXMode = MODE_DMR then + if ActiveTXMode in [MODE_DMR, MODE_FMRAW] then begin if FTuning then SetTune(False) else if FTransmitting then SetMOX(False); @@ -3266,6 +3297,7 @@ begin MODE_FM: Idx := EnsureRange(Idx, 0, FILT_FM_COUNT - 1); MODE_WFM: Idx := EnsureRange(Idx, 0, FILT_WFM_COUNT - 1); MODE_DMR: Idx := 0; + MODE_FMRAW: Idx := EnsureRange(Idx, 0, FILT_RAW_COUNT - 1); else Idx := EnsureRange(Idx, 0, FILT_COUNT - 1); end; FFilter := Idx; @@ -3276,6 +3308,7 @@ begin MODE_CWL, MODE_CWU: FFilterBW := FILT_CW_BW[Idx]; MODE_WFM: FFilterBW := FILT_WFM_BW[Idx]; MODE_DMR: FFilterBW := FILT_DMR_BW; + MODE_FMRAW: FFilterBW := FILT_RAW_BW[Idx]; MODE_FM: begin FFilterBW := FILT_FM_BW[Idx]; @@ -3309,6 +3342,7 @@ begin MODE_FM: for i := 0 to FILT_FM_COUNT-1 do BW_arr[i] := FILT_FM_BW[i]; MODE_WFM: for i := 0 to FILT_WFM_COUNT-1 do BW_arr[i] := FILT_WFM_BW[i]; MODE_DMR: BW_arr[0] := FILT_DMR_BW; + MODE_FMRAW: for i := 0 to FILT_RAW_COUNT-1 do BW_arr[i] := FILT_RAW_BW[i]; else for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_AM_BW[i]; end; best := 0; dist := MaxInt; @@ -3325,6 +3359,15 @@ begin Changed(rfFilter); end; +procedure TRadioController.SetRawFMDeviation(Hz: Integer); +begin + FRawFMDeviation := EnsureRange(Hz, RAW_FM_DEV_MIN, RAW_FM_DEV_MAX); + if FWDSPReady and Assigned(FDSPEngine) then + FDSPEngine.SetRawFMDeviation(FRawFMDeviation); + SaveGlobalSettings; + Changed(rfFMDeviation); +end; + // UI-индекс 0..4 (FAST/MED/SLOW/LONG/OFF) -> WDSP enum. Единый маппинг для всех // фронтендов и слайсов (раньше web/слайдер ошибочно кастовали индекс прямо в enum). class function TRadioController.AGCModeFromUI(M: Integer): TWDSPAGCMode; @@ -4132,7 +4175,7 @@ var begin // DMR support is receive-only. This guard also covers CAT, web and hardware // PTT. A selected non-DMR TX slice remains valid while main A receives DMR. - if On_ and (ActiveTXMode = MODE_DMR) then Exit; + if On_ and (ActiveTXMode in [MODE_DMR, MODE_FMRAW]) then Exit; // Safety: блокируем TX на бэндах с DoNotTx (Alex) и на RX-only трансвертере. if On_ and FDevConnected and (FCurrentXvtr < 0) and FAlexSettings.DoNotTx[EnsureRange(TxBandIdx, 0, 10)] then @@ -4250,7 +4293,7 @@ end; procedure TRadioController.SetTune(On_: Boolean); begin if On_ = FTuning then Exit; - if On_ and (ActiveTXMode = MODE_DMR) then Exit; + if On_ and (ActiveTXMode in [MODE_DMR, MODE_FMRAW]) then Exit; // Safety: те же блокировки, что и в SetMOX — TUN это TX через WDSP PostGen-тон, // он тоже должен молчать на бэндах с DoNotTx и на RX-only XVTR. if On_ and FDevConnected and (FCurrentXvtr < 0) @@ -4364,6 +4407,9 @@ begin FTXSettings.PSPin, FTXSettings.PSMap, FTXSettings.PSStabilize, Ints, Spi, FTXSettings.PSHWPeak); + // Правка частот двухтональника при активном 2TON — применяем живьём. + if FTwoTone then + FDSPEngine.SetTXTwoTone(True, FTXSettings.PSTTFreq1, FTXSettings.PSTTFreq2); end; function TRadioController.PSFeedbackLevel: Integer; @@ -4541,7 +4587,7 @@ procedure TRadioController.SetTwoTone(On_: Boolean); // Drive; тона PSTTFreq1/2 (700/1900 по умолчанию). begin if On_ = FTwoTone then Exit; - if On_ and (ActiveTXMode = MODE_DMR) then Exit; + if On_ and (ActiveTXMode in [MODE_DMR, MODE_FMRAW]) then Exit; if On_ and FTuning then Exit; // не поверх TUN-тона // Safety: те же блокировки, что SetMOX/SetTune. if On_ and FDevConnected and (FCurrentXvtr < 0) @@ -4952,6 +4998,10 @@ begin FDitherEnabled := FLoadedGlobal.DitherEnabled; FRandomEnabled := FLoadedGlobal.RandomEnabled; FSendAudioToRadio := FLoadedGlobal.SendAudioToRadio; + FRawFMDeviation := EnsureRange(FLoadedGlobal.RawFMDeviation, + RAW_FM_DEV_MIN, RAW_FM_DEV_MAX); + if FWDSPReady and Assigned(FDSPEngine) then + FDSPEngine.SetRawFMDeviation(FRawFMDeviation); FWfManualHigh := FLoadedGlobal.WfManualHigh; FWfManualLow := FLoadedGlobal.WfManualLow; FWfAGCOffset := FLoadedGlobal.WfAGCOffset; diff --git a/RadioModes.pas b/RadioModes.pas new file mode 100644 index 0000000..4099bbc --- /dev/null +++ b/RadioModes.pas @@ -0,0 +1,30 @@ +unit RadioModes; + +{$mode objfpc}{$H+} + +// Canonical persisted mode indices. Append only: these values are stored in +// settings.json/channels.json and are exposed through CAT and the web API. +interface + +const + MODE_LSB = 0; + MODE_USB = 1; + MODE_DSB = 2; + MODE_CWL = 3; + MODE_CWU = 4; + MODE_FM = 5; + MODE_AM = 6; + MODE_SAM = 7; + MODE_DIGU = 8; + MODE_DIGL = 9; + MODE_WFM = 10; + MODE_DMR = 11; + MODE_FMRAW = 12; + + MODE_MIN = MODE_LSB; + MODE_MAX = MODE_FMRAW; + MODE_COUNT = MODE_MAX - MODE_MIN + 1; + +implementation + +end. diff --git a/Settings.pas b/Settings.pas index a56e89e..a2ac336 100644 --- a/Settings.pas +++ b/Settings.pas @@ -6,13 +6,15 @@ unit Settings; } {$mode objfpc}{$H+} interface -uses SysUtils, Classes, Math, fpJSON, jsonparser, jsonscanner, PlatformUtils; +uses SysUtils, Classes, Math, fpJSON, jsonparser, jsonscanner, PlatformUtils, + RadioModes; const SETTINGS_FILE = 'hpsdr_settings.json'; CFG_BAND_COUNT = 12; CFG_XVTR_COUNT = 8; // количество XVTR-слотов (трансверторов) - CFG_MODE_MAX = 11; // LSB..DMR; keep persisted-mode validation current + CFG_MODE_MIN = RadioModes.MODE_MIN; + CFG_MODE_MAX = RadioModes.MODE_MAX; CFG_PANS_MAX = 4; // потолок панадаптеров (пан 0 + доп.) — = WDSPEngine.MAX_PANS QO100_SLOT = CFG_XVTR_COUNT - 1; // последний слот зарезервирован под QO-100 // (split-LO транспондер; настраивается на @@ -61,6 +63,16 @@ const FILT_WFM_BW: array[0..2] of Integer = (200000, 160000, 120000); FILT_WFM_DEF = 1; // 160k — влезает и в 192k + // FM RAW: narrow, flat discriminator output for external digital decoders. + // These are RF channel widths; baseband stays flat at 20..7000 Hz. + FILT_RAW_COUNT = 5; + FILT_RAW_BW: array[0..FILT_RAW_COUNT-1] of Integer = + (6250, 10000, 12500, 20000, 25000); + FILT_RAW_DEF = 2; // 12.5 kHz + RAW_FM_DEV_DEFAULT = 2500; + RAW_FM_DEV_MIN = 500; + RAW_FM_DEV_MAX = 10000; + // DMR Tier II: 12.5 kHz RF channel, 4FSK nominal deviation +/-1.944 kHz. FILT_DMR_BW = 12500; DMR_FM_DEV = 2500; @@ -359,6 +371,7 @@ type AudioSampleRate: Integer; // PortAudio stream sample rate (e.g. 48000) AudioInDevice: string; // PortAudio input device name for TX mic ('' = none) SendAudioToRadio: Boolean; // True = send RX audio stream to radio (built-in speaker) + RawFMDeviation: Integer; // Hz, flat RAW FM discriminator scale // --- Visibility settings --- ShowSpectrum: Boolean; // True = draw spectrum on main form ShowWaterfall: Boolean; // True = draw waterfall on main form @@ -566,6 +579,7 @@ begin G.AudioSampleRate := 48000; G.AudioInDevice := ''; G.SendAudioToRadio := False; + G.RawFMDeviation := RAW_FM_DEV_DEFAULT; G.ShowSpectrum := True; G.ShowWaterfall := True; G.ShowWideband := False; @@ -796,6 +810,9 @@ begin G.AudioSampleRate := JI(GObj,'audio_sample_rate',48000); G.AudioInDevice := JS(GObj,'audio_in_device',''); G.SendAudioToRadio := JB(GObj,'send_audio_to_radio',False); + G.RawFMDeviation := EnsureRange( + JI(GObj,'raw_fm_deviation',RAW_FM_DEV_DEFAULT), + RAW_FM_DEV_MIN, RAW_FM_DEV_MAX); // Visibility settings G.ShowSpectrum := JB(GObj,'show_spectrum',True); G.ShowWaterfall := JB(GObj,'show_waterfall',True); @@ -840,7 +857,7 @@ begin BObj := GetBandObj(DevObj, i); Bands[i].VfoA := JD(BObj,'vfo_a', Bands[i].VfoA); Bands[i].VfoB := JD(BObj,'vfo_b', Bands[i].VfoB); - Bands[i].Mode := JI(BObj,'mode', Bands[i].Mode); + Bands[i].Mode := EnsureRange(JI(BObj,'mode', Bands[i].Mode), CFG_MODE_MIN, CFG_MODE_MAX); Bands[i].FilterIdx := JI(BObj,'filter_idx', Bands[i].FilterIdx); Bands[i].FilterBW := JI(BObj,'filter_bw', Bands[i].FilterBW); Bands[i].AGCMode := JI(BObj,'agc_mode', Bands[i].AGCMode); @@ -908,6 +925,7 @@ begin JW(O,'audio_sample_rate',G.AudioSampleRate); JWS(O,'audio_in_device',G.AudioInDevice); JW(O,'send_audio_to_radio',G.SendAudioToRadio); + JW(O,'raw_fm_deviation',G.RawFMDeviation); // Visibility settings JW(O,'show_spectrum',G.ShowSpectrum); JW(O,'show_waterfall',G.ShowWaterfall); @@ -973,7 +991,7 @@ begin O := GetBandObj(GetDevObj(MacStr), BandIdx); B.VfoA := JD(O,'vfo_a', B.VfoA); B.VfoB := JD(O,'vfo_b', B.VfoB); - B.Mode := JI(O,'mode', B.Mode); + B.Mode := EnsureRange(JI(O,'mode', B.Mode), CFG_MODE_MIN, CFG_MODE_MAX); B.FilterIdx := JI(O,'filter_idx', B.FilterIdx); B.FilterBW := JI(O,'filter_bw', B.FilterBW); B.AGCMode := JI(O,'agc_mode', B.AGCMode); @@ -1491,7 +1509,7 @@ begin X.Entries[i].RXAntenna := 0; X.Entries[i].LastFreq := 0.0; X.Entries[i].LastFreqB := 0.0; - X.Entries[i].LastMode := 1; // USB — VHF SSB calling default + X.Entries[i].LastMode := MODE_USB; // VHF SSB calling default X.Entries[i].LastFilterIdx := 0; // NFM X.Entries[i].LastFMSQOn := False; X.Entries[i].LastFMSQLevel := 30; @@ -1572,7 +1590,7 @@ begin X.Entries[i].RXAntenna := EnsureRange(JI(EObj, 'rx_ant', X.Entries[i].RXAntenna), 0, 3); X.Entries[i].LastFreq := JD(EObj, 'last_freq', X.Entries[i].LastFreq); X.Entries[i].LastFreqB := JD(EObj, 'last_freq_b', X.Entries[i].LastFreqB); - X.Entries[i].LastMode := EnsureRange(JI(EObj, 'last_mode', X.Entries[i].LastMode), 0, CFG_MODE_MAX); + X.Entries[i].LastMode := EnsureRange(JI(EObj, 'last_mode', X.Entries[i].LastMode), CFG_MODE_MIN, CFG_MODE_MAX); X.Entries[i].LastFilterIdx := EnsureRange(JI(EObj, 'last_filt', X.Entries[i].LastFilterIdx), 0, 9); X.Entries[i].LastFMSQOn := JB(EObj, 'last_fmsq_on', X.Entries[i].LastFMSQOn); X.Entries[i].LastFMSQLevel := EnsureRange(JI(EObj, 'last_fmsq_lvl', X.Entries[i].LastFMSQLevel), 0, 100); @@ -1908,7 +1926,8 @@ begin 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].Mode := EnsureRange( + JI(SliceObj, 'mode', MODE_LSB), CFG_MODE_MIN, CFG_MODE_MAX); 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); diff --git a/SpectrumView.pas b/SpectrumView.pas index 838689f..165cbba 100644 --- a/SpectrumView.pas +++ b/SpectrumView.pas @@ -21,7 +21,7 @@ uses Classes, SysUtils, Graphics, GraphType, ExtCtrls, Controls, Math, AppTheme, AlertOverlay, SampleRateOverlay, VfoOverlay, BandPlanOverlay, - WaterfallView, SMeterView, RulerView; + WaterfallView, SMeterView, RulerView, RadioModes; const SV_CLR_BG = TColor($00101010); @@ -36,6 +36,16 @@ type LastUse: Int64; end; + // Маркер 2TON/IMD-измерения на спектре: кружок на пике продукта + подпись. + // Тона подписываются уровнем шкалы (dB), IMD-продукты — в dBc от среднего + // уровня тонов. + TIMDMarker = record + FreqHz: Double; // абсолютная частота продукта + LevelDB: Double; // измеренный пик (шкала дисплея) + Caption: string; + IsTone: Boolean; // True = основной тон, False = IMD-продукт + end; + TSpectrumView = class protected // ── Off-screen bitmaps ──────────────────────────────────────────────────── @@ -99,6 +109,11 @@ type FBeaconDecActive: Boolean; FBeaconDecFreq: Double; FBeaconDecHalf: Double; // полуширина фильтра, Гц + // ── 2TON/IMD-маркеры (пики тонов и продуктов IMD3 при двухтональнике) ──── + FIMDActive: Boolean; + FIMDTone1Hz: Double; // абс. частоты тонов (TX freq + аудио-оффсет) + FIMDTone2Hz: Double; + FIMDSummary: string; // сводка «IMD3 −NN dBc» (обновляет ComputeIMDMarkers) // ── Spectrum buffer ─────────────────────────────────────────────────────── FSpectrumBuf: array[0..WF_MAX_PIXELS-1] of Single; FSpectrumBufCount: Integer; @@ -149,6 +164,12 @@ type procedure DrawSliceFilterLinesRaw(W, H: Integer); procedure DrawBandLetterRaw(X1, X2: Integer; L: Char; Clr: TColor); procedure DrawBeaconMarkersRaw(W, H: Integer); + // 2TON/IMD: замер пиков по TX-буферу (общий для CPU/GL рендеров). + // Возвращает число валидных маркеров в M (0 если измерять нечего): + // [0..1] тона, [2..3] IMD3 (2f1−f2, 2f2−f1). Обновляет FIMDSummary. + function ComputeIMDMarkers(var M: array of TIMDMarker): Integer; + procedure DrawIMDMarkersRaw(W, H: Integer; DBmax, InvRange: Double); + procedure RawCircle(CX, CY, R: Integer; Color: TColor); procedure BlendBand(X1, X2, H: Integer; R, G, B, Alpha: Byte); procedure FillBandRaw(X1, X2, H: Integer; Color: TColor); procedure CopyGridToSpectrum(W, H: Integer); @@ -244,6 +265,12 @@ type procedure SetBeaconMarkers(Active: Boolean; RefHz, TrkHz: Double); procedure SetBeaconDecMarker(Active: Boolean; FreqHz, HalfHz: Double); + // 2TON/IMD-маркеры: кружки с уровнями на пиках тонов и продуктов IMD3 + // (замер линейности/PureSignal). Включает MainForm при 2TON+TX+DUP: + // дисплей на RX показывает СВОЙ сигнал после PA — настоящие IMD-плечи + // (на цифровом TX-спектре продуктов PA нет, там замер бессмыслен). + procedure SetIMDMarkers(Active: Boolean; Tone1Hz, Tone2Hz: Double); + // ── S-метр ──────────────────────────────────────────────────────────────── property LastSMeter: Double read GetLastSMeter write SetLastSMeter; property SMeterPeak: Double read GetSMeterPeak write SetSMeterPeak; @@ -653,6 +680,136 @@ begin end; end; +procedure TSpectrumView.SetIMDMarkers(Active: Boolean; Tone1Hz, Tone2Hz: Double); +begin + FIMDActive := Active; + FIMDTone1Hz := Tone1Hz; + FIMDTone2Hz := Tone2Hz; + if not Active then FIMDSummary := ''; +end; + +function TSpectrumView.ComputeIMDMarkers(var M: array of TIMDMarker): Integer; +// Пики меряются по FSpectrumBuf. Основной сценарий — DUP: дисплей на RX +// (TXMode=False), буфер покрывает видимое окно FCenterFreq ± FSpanHz/2, и мы +// видим СВОЙ сигнал после PA (реальные IMD-плечи). При TXMode буфер покрывает +// FTXFreq ± FTXSpanHz/2 (цифровой TX-тракт — оставлено для общности). +// Окно поиска ±250 Гц вокруг ожидаемой частоты — ловим пик независимо от +// джиттера FFT. +var + SrcCount, i: Integer; + ToneAvg, MapLo, MapSpan: Double; + + function PeakDB(FreqHz: Double): Double; + var C, Win, k, i1, i2: Integer; + begin + Result := -999.0; + C := Round((FreqHz - MapLo) / MapSpan * (SrcCount - 1)); + // Ожидаемая частота вне окна буфера (зум/узкий span) — маркер невалиден, + // иначе намеряли бы уровень краевого бина. + if (C < 0) or (C > SrcCount - 1) then Exit; + Win := Max(2, Round(250.0 * (SrcCount - 1) / MapSpan)); + i1 := Max(0, C - Win); + i2 := Min(SrcCount - 1, C + Win); + for k := i1 to i2 do + if FSpectrumBuf[k] > Result then Result := FSpectrumBuf[k]; + end; + +begin + Result := 0; + FIMDSummary := ''; + if not FIMDActive then Exit; + if (FSpectrumBufCount < 2) or (Length(M) < 4) then Exit; + if FTXMode then + begin + if FTXSpanHz <= 0 then Exit; + MapLo := FTXFreq - FTXSpanHz * 0.5; + MapSpan := FTXSpanHz; + end + else + begin + if FSpanHz <= 0 then Exit; + MapLo := FCenterFreq - FSpanHz * 0.5; + MapSpan := FSpanHz; + end; + SrcCount := EnsureRange(FSpectrumBufCount, 2, WF_MAX_PIXELS); + + M[0].FreqHz := FIMDTone1Hz; M[0].IsTone := True; + M[1].FreqHz := FIMDTone2Hz; M[1].IsTone := True; + M[2].FreqHz := 2 * FIMDTone1Hz - FIMDTone2Hz; M[2].IsTone := False; + M[3].FreqHz := 2 * FIMDTone2Hz - FIMDTone1Hz; M[3].IsTone := False; + for i := 0 to 3 do + M[i].LevelDB := PeakDB(M[i].FreqHz); + + ToneAvg := (M[0].LevelDB + M[1].LevelDB) * 0.5; + M[0].Caption := Format('%.0f', [M[0].LevelDB]); + M[1].Caption := Format('%.0f', [M[1].LevelDB]); + // IMD-продукты — в dBc относительно среднего уровня тонов + M[2].Caption := Format('%.0fc', [M[2].LevelDB - ToneAvg]); + M[3].Caption := Format('%.0fc', [M[3].LevelDB - ToneAvg]); + // Сводка только когда оба тона и хотя бы один продукт в окне буфера + if (M[0].LevelDB > -500) and (M[1].LevelDB > -500) and + (Max(M[2].LevelDB, M[3].LevelDB) > -500) then + FIMDSummary := Format('IMD3 %.0f dBc', + [Max(M[2].LevelDB, M[3].LevelDB) - ToneAvg]); + Result := 4; +end; + +procedure TSpectrumView.RawCircle(CX, CY, R: Integer; Color: TColor); +// Залитый кружок радиуса R: по строкам, ширина из уравнения окружности. +var + DY, HalfW, Y, X1, X2, K: Integer; + Px: LongWord; + P: PLongWord; +begin + if FRawBase = nil then Exit; + Px := RawPack(Color); + for DY := -R to R do + begin + Y := CY + DY; + if (Y < 0) or (Y >= FRawH) then Continue; + HalfW := Round(Sqrt(R * R - DY * DY)); + X1 := Max(0, CX - HalfW); + X2 := Min(FRawW - 1, CX + HalfW); + if X2 < X1 then Continue; + P := PLongWord(FRawBase + Y * FRawBPL + X1 * 4); + for K := X1 to X2 do begin P^ := Px; Inc(P); end; + end; +end; + +procedure TSpectrumView.DrawIMDMarkersRaw(W, H: Integer; DBmax, InvRange: Double); +const + CLR_TONE = TColor($0050FF50); // зелёный — основные тона + CLR_IMD = TColor($000A8CFF); // оранжевый — продукты IMD3 +var + M: array[0..3] of TIMDMarker; + N, i, X, Y, TY: Integer; + Col: TColor; +begin + if FSpanHz <= 0 then Exit; + N := ComputeIMDMarkers(M); + if N = 0 then Exit; + for i := 0 to N - 1 do + begin + if M[i].LevelDB < -500 then Continue; // частота вне окна буфера + X := Round((M[i].FreqHz - (FCenterFreq - FSpanHz / 2)) / FSpanHz * W); + if (X < 0) or (X >= W) then Continue; + Y := Max(0, Min(H - 1, Round((DBmax - M[i].LevelDB) * InvRange * H))); + if M[i].IsTone then Col := CLR_TONE else Col := CLR_IMD; + RawCircle(X, Y, 4, Col); + // Подпись правее-выше кружка; у верхней кромки — вниз, у правой — влево. + TY := Y - 14; + if TY < 2 then TY := Y + 6; + if X + 6 + RawTextWidth(M[i].Caption, 7, True) < W then + RawText(X + 6, TY, M[i].Caption, Col, 7, True, clNone) + else + RawText(X - 6 - RawTextWidth(M[i].Caption, 7, True), TY, + M[i].Caption, Col, 7, True, clNone); + end; + // Сводка в левом верхнем углу + if FIMDSummary <> '' then + RawText(6, 6, FIMDSummary, CLR_IMD, 9, True, clNone); +end; + // ════════════════════════════════════════════════════════════════════════════ // Full-raw примитивы кадра (валидны только между RawBegin/RawEnd) // ════════════════════════════════════════════════════════════════════════════ @@ -1098,10 +1255,10 @@ begin Half := BW / 2; VfoX := Round((VfoFreq - FCenterFreq + FSpanHz / 2) / FSpanHz * W); case Mode of - 0: begin Lo_Hz := -BW; Hi_Hz := -100; end; // LSB - 1: begin Lo_Hz := 100; Hi_Hz := BW; end; // USB - 9: begin Lo_Hz := -BW; Hi_Hz := 0; end; // DIGL — полоса от нуля - 8: begin Lo_Hz := 0; Hi_Hz := BW; end; // DIGU + MODE_LSB: begin Lo_Hz := -BW; Hi_Hz := -100; end; + MODE_USB: begin Lo_Hz := 100; Hi_Hz := BW; end; + MODE_DIGL: begin Lo_Hz := -BW; Hi_Hz := 0; end; // полоса от нуля + MODE_DIGU: begin Lo_Hz := 0; Hi_Hz := BW; end; else begin Lo_Hz := -Half; Hi_Hz := Half; end; end; X1 := Max(0, Min(W - 1, VfoX + Round(Lo_Hz / FSpanHz * W))); @@ -1546,6 +1703,9 @@ begin if FMarkerActive then DrawMarkerLineRaw(W, H); if FBeaconMarkActive or FBeaconDecActive then DrawBeaconMarkersRaw(W, H); + // 2TON: кружки с уровнями на пиках тонов/IMD3 (активирует MainForm при + // 2TON+TX+DUP — реальный сигнал после PA виден на RX-спектре) + if FIMDActive then DrawIMDMarkersRaw(W, H, DBmax, InvRange); // Композиты оверлеев — внутри внешнего лока: их собственные BeginUpdate/ diff --git a/SpectrumViewOpengl.pas b/SpectrumViewOpengl.pas index c1c6220..4b7d605 100644 --- a/SpectrumViewOpengl.pas +++ b/SpectrumViewOpengl.pas @@ -57,6 +57,9 @@ type FLastAGCHangY: Integer; FLastMarkerText: string; FLastMarkerX: Integer; + // 2TON/IMD: текстуры подписей маркеров (0..3 = кружки, 4 = сводка IMD3) + FIMDLabelTex: array[0..4] of TGLTextureCache; + FIMDLastText: array[0..4] of string; FLastADCVisible: Boolean; FLastSampleX: Integer; FLastSampleY: Integer; @@ -90,6 +93,8 @@ type procedure DrawSpectrumCurve(W, H: Integer; DBmax, InvRange: Double); procedure DrawMarker(W, H: Integer); procedure DrawBeaconMarkersGL(W, H: Integer); + procedure DrawCircleGL(CX, CY: Integer; R: Single; C: TColor); + procedure DrawIMDMarkersGL(W, H: Integer; DBmax, InvRange: Double); procedure DrawADCOverlay(W, H: Integer); procedure DrawCachedOverlays(W, H: Integer); procedure DrawSliceOverlays(W, H: Integer); @@ -170,6 +175,7 @@ begin DeleteTexture(FBandOverlayTex); for i := 0 to High(FSliceTex) do DeleteTexture(FSliceTex[i].Tex); for i := 0 to High(FLetterTex) do DeleteTexture(FLetterTex[i]); + for i := 0 to High(FIMDLabelTex) do DeleteTexture(FIMDLabelTex[i]); end; SetLength(FSliceTex, 0); inherited Destroy; @@ -451,6 +457,11 @@ begin Zap(FBandOverlayTex); for i := 0 to High(FSliceTex) do Zap(FSliceTex[i].Tex); for i := 0 to High(FLetterTex) do Zap(FLetterTex[i]); + for i := 0 to High(FIMDLabelTex) do + begin + Zap(FIMDLabelTex[i]); + FIMDLastText[i] := ''; + end; FLastAGCText := ''; FLastMarkerText := ''; FGridDirty := True; @@ -803,6 +814,71 @@ begin end; end; +procedure TSpectrumViewOpenGL.DrawCircleGL(CX, CY: Integer; R: Single; C: TColor); +// Залитый кружок (triangle fan, 20 сегментов) — маркер пика 2TON/IMD. +var + CR, CG, CB: GLFloat; + a: Integer; + Ang: Single; +begin + ColorToRGB(C, CR, CG, CB); + glColor4f(CR, CG, CB, 1); + glBegin(GL_TRIANGLE_FAN); + glVertex2f(CX + 0.5, CY + 0.5); + for a := 0 to 20 do + begin + Ang := a * 2 * Pi / 20; + glVertex2f(CX + 0.5 + R * Cos(Ang), CY + 0.5 + R * Sin(Ang)); + end; + glEnd; +end; + +procedure TSpectrumViewOpenGL.DrawIMDMarkersGL(W, H: Integer; + DBmax, InvRange: Double); +// GL-вариант DrawIMDMarkersRaw: замер общий (ComputeIMDMarkers), подписи — +// текстуры с кэшем по строке (перезаливка только при смене значения). +const + CLR_TONE = TColor($0050FF50); // зелёный — основные тона + CLR_IMD = TColor($000A8CFF); // оранжевый — продукты IMD3 +var + M: array[0..3] of TIMDMarker; + N, i, X, Y, TX, TY: Integer; + Col: TColor; +begin + if FSpanHz <= 0 then Exit; + N := ComputeIMDMarkers(M); + if N = 0 then Exit; + for i := 0 to N - 1 do + begin + if M[i].LevelDB < -500 then Continue; // частота вне окна буфера + X := Round((M[i].FreqHz - (FCenterFreq - FSpanHz / 2)) / FSpanHz * W); + if (X < 0) or (X >= W) then Continue; + Y := Max(0, Min(H - 1, Round((DBmax - M[i].LevelDB) * InvRange * H))); + if M[i].IsTone then Col := CLR_TONE else Col := CLR_IMD; + DrawCircleGL(X, Y, 4.0, Col); + if (M[i].Caption <> FIMDLastText[i]) or FIMDLabelTex[i].Dirty then + begin + UploadText(FIMDLabelTex[i], M[i].Caption, Col, 7, True); + FIMDLastText[i] := M[i].Caption; + end; + TY := Y - 14; + if TY < 2 then TY := Y + 6; + TX := X + 6; + if TX + FIMDLabelTex[i].W >= W then TX := X - 6 - FIMDLabelTex[i].W; + DrawTexture(FIMDLabelTex[i], TX, TY); + end; + // Сводка в левом верхнем углу + if FIMDSummary <> '' then + begin + if (FIMDSummary <> FIMDLastText[4]) or FIMDLabelTex[4].Dirty then + begin + UploadText(FIMDLabelTex[4], FIMDSummary, CLR_IMD, 9, True); + FIMDLastText[4] := FIMDSummary; + end; + DrawTexture(FIMDLabelTex[4], 6, 6); + end; +end; + procedure TSpectrumViewOpenGL.DrawADCOverlay(W, H: Integer); var B: TBitmap; @@ -1016,6 +1092,9 @@ begin DrawSpectrumCurve(W, H, DBmax, InvRange); DrawMarker(W, H); if FBeaconMarkActive or FBeaconDecActive then DrawBeaconMarkersGL(W, H); + // 2TON: кружки с уровнями на пиках тонов/IMD3 (активирует MainForm при + // 2TON+TX+DUP — реальный сигнал после PA виден на RX-спектре) + if FIMDActive then DrawIMDMarkersGL(W, H, DBmax, InvRange); DrawADCOverlay(W, H); DrawCachedOverlays(W, H); diff --git a/VfoOverlay.pas b/VfoOverlay.pas index 5177417..d7f1827 100644 --- a/VfoOverlay.pas +++ b/VfoOverlay.pas @@ -17,18 +17,22 @@ unit VfoOverlay; interface uses - Classes, SysUtils, Controls, Graphics, Types, Math, FlatButton, AppTheme; + Classes, SysUtils, Controls, Graphics, Types, Math, FlatButton, AppTheme, + RadioModes; const VFO_OVERLAY_ALPHA = 210; OVL_W = 280; // ширина карточки // Индексы совпадают с MODE_* контроллера (WDSPEngine.pas). - OVL_MODE_COUNT = 12; + OVL_MODE_COUNT = RadioModes.MODE_COUNT; + OVL_MODE_FM = RadioModes.MODE_FM; + OVL_MODE_DMR = RadioModes.MODE_DMR; + OVL_MODE_FMRAW = RadioModes.MODE_FMRAW; OVL_MODE_COLS = 4; OVL_MODE_ROWS = (OVL_MODE_COUNT + OVL_MODE_COLS - 1) div OVL_MODE_COLS; OVL_MODE_NAMES: array[0..OVL_MODE_COUNT-1] of string = ( - 'LSB','USB','DSB','CWL','CWU','FM','AM','SAM','DIGU','DIGL','WFM','DMR'); + 'LSB','USB','DSB','CWL','CWU','FM','AM','SAM','DIGU','DIGL','WFM','DMR','FMRAW'); // Палитра слайсов (TColor = $00BBGGRR): одна на букву — общий цвет для // бейджа-карточки во флаге и для буквы на полосе фильтра в спектре. @@ -307,6 +311,8 @@ const WFM_LBL: array[0..2] of string = ('200K', '160K', '120K'); DIGI_BW: array[0..5] of Integer = (300, 600, 1200, 1800, 2400, 3000); DIGI_LBL: array[0..5] of string = ('300','600','1.2K','1.8K','2.4K','3.0K'); + RAW_BW: array[0..4] of Integer = (6250,10000,12500,20000,25000); + RAW_LBL: array[0..4] of string = ('6.25K','10K','12.5K','20K','25K'); DB_S: array[1..9] of Double = (-121,-115,-109,-103,-97,-91,-85,-79,-73); @@ -400,13 +406,14 @@ end; function TVfoOverlay.GetFilterBWForMode(ModeIdx, FilterIdx: Integer): Integer; begin case ModeIdx of - 0,1: Result := SSB_BW[FilterIdx]; - 2: Result := DSB_BW[FilterIdx]; - 3,4: Result := CW_BW[FilterIdx]; - 5: Result := FM_BW[FilterIdx]; - 8,9: Result := DIGI_BW[FilterIdx]; // DIGU/DIGL — полоса от нуля - 10: Result := WFM_BW[FilterIdx]; - 11: Result := 12500; + MODE_LSB, MODE_USB: Result := SSB_BW[FilterIdx]; + MODE_DSB: Result := DSB_BW[FilterIdx]; + MODE_CWL, MODE_CWU: Result := CW_BW[FilterIdx]; + OVL_MODE_FM: Result := FM_BW[FilterIdx]; + MODE_DIGU, MODE_DIGL: Result := DIGI_BW[FilterIdx]; // полоса от нуля + MODE_WFM: Result := WFM_BW[FilterIdx]; + OVL_MODE_DMR: Result := 12500; + OVL_MODE_FMRAW: Result := RAW_BW[FilterIdx]; else Result := AM_BW[FilterIdx]; end; end; @@ -414,13 +421,14 @@ end; function TVfoOverlay.GetFilterLblForMode(ModeIdx, FilterIdx: Integer): string; begin case ModeIdx of - 0,1: Result := SSB_LBL[FilterIdx]; - 2: Result := DSB_LBL[FilterIdx]; - 3,4: Result := CW_LBL[FilterIdx]; - 5: Result := FM_LBL[FilterIdx]; - 8,9: Result := DIGI_LBL[FilterIdx]; - 10: Result := WFM_LBL[FilterIdx]; - 11: Result := '12.5k'; + MODE_LSB, MODE_USB: Result := SSB_LBL[FilterIdx]; + MODE_DSB: Result := DSB_LBL[FilterIdx]; + MODE_CWL, MODE_CWU: Result := CW_LBL[FilterIdx]; + OVL_MODE_FM: Result := FM_LBL[FilterIdx]; + MODE_DIGU, MODE_DIGL: Result := DIGI_LBL[FilterIdx]; + MODE_WFM: Result := WFM_LBL[FilterIdx]; + OVL_MODE_DMR: Result := '12.5k'; + OVL_MODE_FMRAW: Result := RAW_LBL[FilterIdx]; else Result := AM_LBL[FilterIdx]; end; end; @@ -428,9 +436,10 @@ end; function TVfoOverlay.GetFilterCountForMode(ModeIdx: Integer): Integer; begin case ModeIdx of - 5: Result := 2; // FM: NFM/FM - 10: Result := 3; // WFM: 200K/160K/120K - 11: Result := 0; // DMR: fixed 12.5 kHz, no redundant selector + OVL_MODE_FM: Result := 2; // FM: NFM/FM + MODE_WFM: Result := 3; // WFM: 200K/160K/120K + OVL_MODE_DMR: Result := 0; // DMR: fixed 12.5 kHz, no redundant selector + OVL_MODE_FMRAW: Result := 5; // FM RAW decoder channel widths else Result := 6; end; end; @@ -438,7 +447,7 @@ end; function TVfoOverlay.TxSelectionAllowed: Boolean; begin // Main A remains the normal TX source. Additional DMR slices are RX-only. - Result := FIsMain or (FMode <> 11); + Result := FIsMain or not (FMode in [OVL_MODE_DMR, OVL_MODE_FMRAW]); end; { ---- Lifecycle ---- } @@ -447,7 +456,7 @@ constructor TVfoOverlay.Create(AOwner: TComponent); begin inherited Create(AOwner); ControlStyle := ControlStyle + [csOpaque]; - FMode := 1; + FMode := MODE_USB; FFilterBW := 2700; FVfoHz := 14200000; FSMeterDB := -121; @@ -457,6 +466,7 @@ begin FModeFilter[3] := 500; FModeFilter[4] := 500; FModeFilter[5] := 11000; FModeFilter[6] := 8000; FModeFilter[7] := 8000; FModeFilter[8] := 3000; FModeFilter[9] := 3000; FModeFilter[10] := 160000; + FModeFilter[11] := 12500; FModeFilter[12] := 12500; FNRMode := 0; FNBMode := 0; FSNBOn := False; FANFOn := False; FAGCMode := 0; FSQLOn := False; FSQLLevel := 40; FSqlDrag := False; FVolume := 70; FMuted := False; FSplit := False; FTx := False; @@ -594,7 +604,7 @@ end; function TVfoOverlay.IsFM: Boolean; begin - Result := FMode = 5; // индекс FM в OVL_MODE_NAMES (= MODE_FM контроллера) + Result := FMode = OVL_MODE_FM; end; function TVfoOverlay.PanelContentH: Integer; @@ -1039,7 +1049,7 @@ begin // Краткий статус дополнительного DMR-слайса. Он обновляется только при // устойчивом изменении sync/slot/CC/TG, поэтому не заставляет GL-текстуру // перезаливаться на каждом burst. - if (not FIsMain) and (FMode = 11) and (FDMRText <> '') then + if (not FIsMain) and (FMode = OVL_MODE_DMR) and (FDMRText <> '') then begin DMRStr := FDMRText; DMRX := X + 22 + C.TextWidth(BWStr) + 8; @@ -1276,7 +1286,7 @@ begin begin FModeFilter[FMode] := FFilterBW; FMode := NewMode; - if FMode = 11 then FFilterBW := 12500 + if FMode = OVL_MODE_DMR then FFilterBW := 12500 else FFilterBW := FModeFilter[FMode]; Height := ComputeHeight; // число фильтров могло смениться (FM=2, WFM=3) RequestInvalidate; @@ -1449,19 +1459,19 @@ var OldMode: Integer; begin OldMode := FMode; - FMode := Max(0, Min(OVL_MODE_COUNT - 1, AMode)); + FMode := EnsureRange(AMode, MODE_MIN, MODE_MAX); FFilterBW := ABW; FModeFilter[FMode] := ABW; FVfoHz := AVfoHz; FSMeterDB := ASMeterDB; - if FMode <> 11 then + if FMode <> OVL_MODE_DMR then begin FDMRText := ''; FDMRSynced := False; FDMRCandidateText := ''; FDMRCandidateSince := 0; end - else if OldMode <> 11 then + else if OldMode <> OVL_MODE_DMR then begin FDMRText := 'DMR...'; FDMRSynced := False; @@ -1555,7 +1565,7 @@ procedure TVfoOverlay.UpdateDMRStatus(const AText: string; Synced: Boolean); var NowTick, HoldMs: QWord; begin - if FMode <> 11 then Exit; + if FMode <> OVL_MODE_DMR then Exit; if (FDMRText = AText) and (FDMRSynced = Synced) then begin FDMRCandidateText := ''; diff --git a/WDSPEngine.pas b/WDSPEngine.pas index eea8d62..db66e52 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -25,7 +25,7 @@ interface uses Classes, SysUtils, Math, SyncObjs, - BeaconDecoder, + BeaconDecoder, RadioModes, WDSP; const @@ -49,18 +49,22 @@ const // Наши индексы режимов (совпадают с кнопками MainForm). // Новые режимы дописываются В КОНЕЦ: индекс уходит в settings.json, // band-cache, CAT (ZZMD/ZZMV) и web-API. - MODE_LSB = 0; - MODE_USB = 1; - MODE_DSB = 2; - MODE_CWL = 3; - MODE_CWU = 4; - MODE_FM = 5; - MODE_AM = 6; - MODE_SAM = 7; - MODE_DIGU = 8; - MODE_DIGL = 9; - MODE_WFM = 10; - MODE_DMR = 11; // DMR receive: flat WDSP discriminator -> DMRDecoder + MODE_LSB = RadioModes.MODE_LSB; + MODE_USB = RadioModes.MODE_USB; + MODE_DSB = RadioModes.MODE_DSB; + MODE_CWL = RadioModes.MODE_CWL; + MODE_CWU = RadioModes.MODE_CWU; + MODE_FM = RadioModes.MODE_FM; + MODE_AM = RadioModes.MODE_AM; + MODE_SAM = RadioModes.MODE_SAM; + MODE_DIGU = RadioModes.MODE_DIGU; + MODE_DIGL = RadioModes.MODE_DIGL; + MODE_WFM = RadioModes.MODE_WFM; + MODE_DMR = RadioModes.MODE_DMR; + MODE_FMRAW = RadioModes.MODE_FMRAW; + MODE_MIN = RadioModes.MODE_MIN; + MODE_MAX = RadioModes.MODE_MAX; + MODE_COUNT = RadioModes.MODE_COUNT; // Минимальная длина FIR у WDSP: create_* поднимают fircore с // nc = max(2048, dsp_size). В firmin.c nfor = nc / size, idxmask = nfor - 1, @@ -443,6 +447,7 @@ type FTXWfAvgTimeMS: Double; FMode: Integer; + FRawFMDeviation: Double; FSMeter: Double; FFilterLow: Integer; FFilterHigh: Integer; @@ -479,6 +484,7 @@ type procedure ApplyDSPRate(Mode: Integer); procedure ApplyRXWFMToChan(Chan: Integer); procedure ApplyRXDMRToChan(Chan: Integer); + procedure ApplyRXRawToChan(Chan: Integer); procedure ApplyWFMSettings; // --- Мультислайсы --- function FindSliceIdx(Id: Integer): Integer; // -1 если нет @@ -615,6 +621,7 @@ type procedure SetSNB(Enable: Boolean); procedure SetANF(Enable: Boolean); procedure SetRXFMDeviation(Deviation: Double); + procedure SetRawFMDeviation(Deviation: Double); procedure SetFMSquelch(Enable: Boolean; Level: Integer); procedure SetVolume(Vol: Double); procedure SetMute(Mute: Boolean); @@ -991,6 +998,7 @@ begin // de-emphasis. Those filters collapse the four DMR discriminator levels. // WFMD is a plain quadrature discriminator and permits flat data audio. MODE_DMR: Result := WDSP_RX_WFM; + MODE_FMRAW: Result := WDSP_RX_WFM; else Result := WDSP_USB; end; end; @@ -999,7 +1007,7 @@ function TWDSPEngine.ModeToWDSPTX(Mode: Integer): Integer; // txaMode расходится с rxaMode после DRM — отдельный маппинг только для WFM. begin if Mode = MODE_WFM then Result := WDSP_TX_WFM - else if Mode = MODE_DMR then Result := WDSP_FM + else if Mode in [MODE_DMR, MODE_FMRAW] then Result := WDSP_FM else Result := ModeToWDSP(Mode); end; @@ -1122,6 +1130,15 @@ begin SetRXAWFMDeemphRun (Chan, 0); end; +procedure TWDSPEngine.ApplyRXRawToChan(Chan: Integer); +begin + // Decoder-grade discriminator: no broadcast de-emphasis or limiter. + SetRXAWFMDeviation (Chan, FRawFMDeviation); + SetRXAWFMAFFilter (Chan, 20.0, 7000.0); + SetRXAWFMDeemphRun (Chan, 0); + SetRXAWFMLimRun (Chan, 0); +end; + procedure TWDSPEngine.ApplyWFMSettings; begin ApplyRXWFMToChan(RXA_CHAN); @@ -1153,6 +1170,7 @@ begin MODE_DIGL: SetFilter(-3000, 0); MODE_WFM: SetFilter(-80000, 80000); MODE_DMR: SetFilter(-6250, 6250); + MODE_FMRAW: SetFilter(-6250, 6250); end; end; @@ -1306,6 +1324,7 @@ begin FTXEQFreqs[10] := 3500; FTXAMCarrier := 0.5; FTXFMDeviation := 5000.0; + FRawFMDeviation := 2500.0; FTXFMLowCut := 300; FTXFMHighCut := 3000; FTXFMEmphPos := 1; @@ -2312,6 +2331,7 @@ begin SetRXAMode(Chan, ModeToWDSP(FSlices[Idx].Mode)); if FSlices[Idx].Mode = MODE_WFM then ApplyRXWFMToChan(Chan); if FSlices[Idx].Mode = MODE_DMR then ApplyRXDMRToChan(Chan); + if FSlices[Idx].Mode = MODE_FMRAW then ApplyRXRawToChan(Chan); SetRXAShiftRun(Chan, 1); SetRXAShiftFreq(Chan, FSlices[Idx].ShiftHz); RXASetPassband(Chan, ClampToNyquist(FSlices[Idx].FilterLo, DSPRate), @@ -2324,7 +2344,7 @@ begin ApplyANFToChan(Chan, FSlices[Idx].ANFOn); ApplyFMSQToChan(Chan, FSlices[Idx].FMSQOn, FSlices[Idx].FMSQLevel); // Decoder input must not disappear or change scale with speaker volume. - if FSlices[Idx].Mode = MODE_DMR then + if FSlices[Idx].Mode in [MODE_DMR, MODE_FMRAW] then SetRXAPanelGain1(Chan, 1.0) else SetRXAPanelGain1(Chan, FSlices[Idx].Volume); @@ -2374,7 +2394,7 @@ begin if not (FSlices[s].Active and FSlices[s].Opened) then Continue; if FSlices[s].PanId <> PanId then Continue; // Muting a DMR speaker must not make its decoder lose synchronization. - if FSlices[s].Muted and (FSlices[s].Mode <> MODE_DMR) then Continue; + if FSlices[s].Muted and not (FSlices[s].Mode in [MODE_DMR, MODE_FMRAW]) then Continue; // Свежая копия входного IQ — fexchange0 портит вход in-place. for i := 0 to N - 1 do begin @@ -2390,12 +2410,12 @@ begin FSliceOutL[k] := FSliceOut[k * 2]; FSliceOutR[k] := FSliceOut[k * 2 + 1]; end; - if FSlices[s].Mode = MODE_DMR then + if FSlices[s].Mode in [MODE_DMR, MODE_FMRAW] then begin if Assigned(FOnSliceDemodAudio) then FOnSliceDemodAudio(FSlices[s].Id, FSliceOutL, FSliceOutR, FAudioBufSize); - Continue; // never route the raw 4FSK discriminator to the speaker + Continue; // decoder-grade signal uses the independent pre-volume route end; if Assigned(FOnSliceAudio) then begin @@ -2616,7 +2636,8 @@ begin SetRXAMode(FSlices[idx].Chan, ModeToWDSP(Mode)); if Mode = MODE_WFM then ApplyRXWFMToChan(FSlices[idx].Chan); if Mode = MODE_DMR then ApplyRXDMRToChan(FSlices[idx].Chan); - if Mode = MODE_DMR then + if Mode = MODE_FMRAW then ApplyRXRawToChan(FSlices[idx].Chan); + if Mode in [MODE_DMR, MODE_FMRAW] then SetRXAPanelGain1(FSlices[idx].Chan, 1.0) else SetRXAPanelGain1(FSlices[idx].Chan, FSlices[idx].Volume); @@ -2665,7 +2686,7 @@ begin try idx := FindSliceIdx(Id); if idx < 0 then Exit; FSlices[idx].Volume := Vol; - if FSlices[idx].Opened and (FSlices[idx].Mode <> MODE_DMR) then + if FSlices[idx].Opened and not (FSlices[idx].Mode in [MODE_DMR, MODE_FMRAW]) then SetRXAPanelGain1(FSlices[idx].Chan, Vol); finally FSliceLock.Leave; @@ -2810,7 +2831,8 @@ begin SetRXAWFMDeviation (RXA_CHAN, DMR_DEVIATION); SetRXAWFMAFFilter (RXA_CHAN, 20.0, 7000.0); SetRXAWFMDeemphRun (RXA_CHAN, 0); - end; + end + else if Mode = MODE_FMRAW then ApplyRXRawToChan(RXA_CHAN); // НЕ вызываем ApplyDefaultFilter — фильтр устанавливается явно из UI end; @@ -3046,12 +3068,31 @@ end; procedure TWDSPEngine.SetRXFMDeviation(Deviation: Double); begin if not FInitialized then Exit; - if FMode = MODE_DMR then + if FMode = MODE_FMRAW then SetRawFMDeviation(Deviation) + else if FMode = MODE_DMR then SetRXAWFMDeviation(RXA_CHAN, Deviation) else SetRXAFMDeviation(RXA_CHAN, Deviation); end; +procedure TWDSPEngine.SetRawFMDeviation(Deviation: Double); +var i: Integer; +begin + FRawFMDeviation := EnsureRange(Deviation, 500.0, 10000.0); + if not FInitialized then Exit; + if FMode = MODE_FMRAW then SetRXAWFMDeviation(RXA_CHAN, FRawFMDeviation); + // One calibration value applies to every active FM RAW decoder output. + FSliceLock.Enter; + try + for i := 0 to MAX_SLICES - 1 do + if FSlices[i].Active and FSlices[i].Opened and + (FSlices[i].Mode = MODE_FMRAW) then + SetRXAWFMDeviation(FSlices[i].Chan, FRawFMDeviation); + finally + FSliceLock.Leave; + end; +end; + procedure TWDSPEngine.SetFMSquelch(Enable: Boolean; Level: Integer); var Threshold: Double; begin diff --git a/WebAdapter.pas b/WebAdapter.pas index c827ca2..d92b5ce 100644 --- a/WebAdapter.pas +++ b/WebAdapter.pas @@ -145,7 +145,7 @@ begin FSyncInt := Mode; FController.Invoke(@SyncMode); end; procedure TWebAdapter.SyncMode; begin - if (FSyncInt < 0) or (FSyncInt > MODE_DMR) then Exit; + if (FSyncInt < MODE_MIN) or (FSyncInt > MODE_MAX) then Exit; FController.SetMode(FSyncInt); // дефолт фильтра + DSP + cache; рендер через rfMode end; diff --git a/WebPageHtml.pas b/WebPageHtml.pas index 900bca1..360d598 100644 --- a/WebPageHtml.pas +++ b/WebPageHtml.pas @@ -193,7 +193,7 @@ begin '' + '