diff --git a/AudioOutput.pas b/AudioOutput.pas index 93d1db0..c17c705 100644 --- a/AudioOutput.pas +++ b/AudioOutput.pas @@ -56,10 +56,25 @@ type statusFlags: LongWord; userData: Pointer): LongInt; cdecl; + TPaDeviceInfo = record + structVersion: Integer; + name: PAnsiChar; + hostApi: Integer; + maxInputChannels: Integer; + maxOutputChannels: Integer; + defaultLowInputLatency: TPaTime; + defaultLowOutputLatency: TPaTime; + defaultHighInputLatency: TPaTime; + defaultHighOutputLatency:TPaTime; + defaultSampleRate: Double; + end; + PPaDeviceInfo = ^TPaDeviceInfo; + TPa_Initialize = function: TPaError; cdecl; TPa_Terminate = function: TPaError; cdecl; TPa_GetDefaultOutputDevice = function: TPaDeviceIndex; cdecl; - TPa_GetDeviceInfo = function(device: TPaDeviceIndex): Pointer; cdecl; + TPa_GetDeviceCount = function: TPaDeviceIndex; cdecl; + TPa_GetDeviceInfo = function(device: TPaDeviceIndex): PPaDeviceInfo; cdecl; TPa_OpenStream = function(stream: PPaStream; inputParam: PPaStreamParameters; outputParam: PPaStreamParameters; @@ -89,10 +104,13 @@ type FOutPt: Integer; // audio_buffer_outpt (read) FMutex: TCriticalSection; + FDeviceIndex: Integer; // -1 = default output device // PortAudio functions FPa_Initialize: TPa_Initialize; FPa_Terminate: TPa_Terminate; FPa_GetDefaultOutputDevice: TPa_GetDefaultOutputDevice; + FPa_GetDeviceCount: TPa_GetDeviceCount; + FPa_GetDeviceInfo: TPa_GetDeviceInfo; FPa_OpenStream: TPa_OpenStream; FPa_StartStream: TPa_StartStream; FPa_StopStream: TPa_StopStream; @@ -106,16 +124,28 @@ type constructor Create(SampleRate: Integer = 48000); destructor Destroy; override; + function Initialize: Boolean; // загружает PA, вызывает Pa_Initialize (без открытия стрима) function Open: Boolean; procedure Close; + // Перечисление устройств вывода: заполняет Names списком имён. + // Возвращает True если PA инициализирована и устройства доступны. + // Indices[i] — индекс PA устройства для Names[i]. + function EnumOutputDevices(Names: TStrings; out Indices: array of Integer; + out Count: Integer): Boolean; + + // Найти индекс устройства по имени (-1 если не найдено) + function FindDeviceByName(const AName: string): Integer; + // Пишем стерео double сэмплы — как audio_write() в оригинале procedure WriteDouble(Left, Right: Double); // Convenience: массив Single procedure Write(const Left, Right: array of Single; Count: Integer); - property IsOpen: Boolean read FOpen; - property LastError: string read GetLastError; + property IsOpen: Boolean read FOpen; + property LastError: string read GetLastError; + property DeviceIndex: Integer read FDeviceIndex write FDeviceIndex; + property SampleRate: Integer read FSampleRate write FSampleRate; end; // Глобальный callback (cdecl, не метод) @@ -202,12 +232,13 @@ end; constructor TAudioOutput.Create(SampleRate: Integer); begin inherited Create; - FSampleRate := SampleRate; - FOpen := False; - FPAInited := False; - FStream := nil; - FLibHandle := NilHandle; - FLastError := ''; + FSampleRate := SampleRate; + FDeviceIndex := -1; // -1 = default output device + FOpen := False; + FPAInited := False; + FStream := nil; + FLibHandle := NilHandle; + FLastError := ''; FInPt := 0; FOutPt := 0; FillChar(FBuf, SizeOf(FBuf), 0); @@ -258,6 +289,8 @@ begin FPa_Initialize := TPa_Initialize(GetProcAddress(FLibHandle, 'Pa_Initialize')); FPa_Terminate := TPa_Terminate(GetProcAddress(FLibHandle, 'Pa_Terminate')); FPa_GetDefaultOutputDevice := TPa_GetDefaultOutputDevice(GetProcAddress(FLibHandle, 'Pa_GetDefaultOutputDevice')); + FPa_GetDeviceCount := TPa_GetDeviceCount(GetProcAddress(FLibHandle, 'Pa_GetDeviceCount')); + FPa_GetDeviceInfo := TPa_GetDeviceInfo(GetProcAddress(FLibHandle, 'Pa_GetDeviceInfo')); FPa_OpenStream := TPa_OpenStream(GetProcAddress(FLibHandle, 'Pa_OpenStream')); FPa_StartStream := TPa_StartStream(GetProcAddress(FLibHandle, 'Pa_StartStream')); FPa_StopStream := TPa_StopStream(GetProcAddress(FLibHandle, 'Pa_StopStream')); @@ -275,6 +308,105 @@ begin Result := True; end; +// --------------------------------------------------------------------------- +// Initialize — загружает PA библиотеку и вызывает Pa_Initialize без открытия стрима. +// Нужно для перечисления устройств в настройках до старта аудио. +// --------------------------------------------------------------------------- + +function TAudioOutput.Initialize: Boolean; +var + Err: TPaError; +begin + Result := False; + if not LoadLib then Exit; + if FPAInited then begin Result := True; Exit; end; + Err := FPa_Initialize(); + if Err <> PA_NO_ERROR then + begin + FLastError := 'Pa_Initialize: '; + if Assigned(FPa_GetErrorText) then + FLastError := FLastError + string(FPa_GetErrorText(Err)) + else + FLastError := FLastError + IntToStr(Err); + Exit; + end; + FPAInited := True; + Result := True; +end; + +// --------------------------------------------------------------------------- +// EnumOutputDevices — перечисляет устройства вывода. +// Names получает имена устройств; Indices — соответствующие PA-индексы; +// Count — реальное количество (может быть меньше Length(Indices)). +// --------------------------------------------------------------------------- + +function TAudioOutput.EnumOutputDevices(Names: TStrings; + out Indices: array of Integer; out Count: Integer): Boolean; +var + I, N: Integer; + Info: PPaDeviceInfo; + DevName: string; +begin + Count := 0; + Result := False; + if not Initialize then Exit; + if not Assigned(FPa_GetDeviceCount) or not Assigned(FPa_GetDeviceInfo) then Exit; + + N := FPa_GetDeviceCount(); + if N <= 0 then Exit; + + for I := 0 to N - 1 do + begin + Info := FPa_GetDeviceInfo(I); + if Info = nil then Continue; + if Info^.maxOutputChannels <= 0 then Continue; + if Count >= Length(Indices) then Break; + + if Info^.name <> nil then + DevName := string(AnsiString(Info^.name)) + else + DevName := Format('Device %d', [I]); + + Names.Add(DevName); + Indices[Count] := I; + Inc(Count); + end; + Result := Count > 0; +end; + +// --------------------------------------------------------------------------- +// FindDeviceByName — возвращает PA device index по имени (-1 если не найден). +// --------------------------------------------------------------------------- + +function TAudioOutput.FindDeviceByName(const AName: string): Integer; +var + I, N: Integer; + Info: PPaDeviceInfo; + DevName: string; +begin + Result := -1; + if AName = '' then Exit; + if not Initialize then Exit; + if not Assigned(FPa_GetDeviceCount) or not Assigned(FPa_GetDeviceInfo) then Exit; + + N := FPa_GetDeviceCount(); + for I := 0 to N - 1 do + begin + Info := FPa_GetDeviceInfo(I); + if Info = nil then Continue; + if Info^.maxOutputChannels <= 0 then Continue; + if Info^.name <> nil then + DevName := string(AnsiString(Info^.name)) + else + DevName := Format('Device %d', [I]); + if SameText(DevName, AName) then + begin + Result := I; + Exit; + end; + end; +end; + // --------------------------------------------------------------------------- // Open — точная последовательность как в audio_open_output() // --------------------------------------------------------------------------- @@ -287,28 +419,18 @@ var begin Result := False; if FOpen then begin Result := True; Exit; end; - if not LoadLib then Exit; + if not Initialize then Exit; // загрузка PA + Pa_Initialize - // Pa_Initialize — один раз (как в audio_get_cards) - if not FPAInited then + // Выбор устройства: FDeviceIndex или default + if FDeviceIndex >= 0 then + Dev := FDeviceIndex + else begin - Err := FPa_Initialize(); - if Err <> PA_NO_ERROR then - begin - FLastError := 'Pa_Initialize: '; - if Assigned(FPa_GetErrorText) then - FLastError := FLastError + string(FPa_GetErrorText(Err)) - else - FLastError := FLastError + IntToStr(Err); - Exit; - end; - FPAInited := True; + Dev := FPa_GetDefaultOutputDevice(); end; - - Dev := FPa_GetDefaultOutputDevice(); if Dev = PA_NO_DEV then begin - FLastError := 'No default output device'; + FLastError := 'No output device available'; Exit; end; diff --git a/HPSDRNetwork.pas b/HPSDRNetwork.pas index 5ee7ff0..7b5b4de 100644 --- a/HPSDRNetwork.pas +++ b/HPSDRNetwork.pas @@ -19,7 +19,7 @@ uses {$ELSE} Sockets, BaseUnix, {$ENDIF} - HPSDRProtocol; + HPSDRProtocol, SyncObjs; {$IFDEF WINDOWS} // --------------------------------------------------------------------------- @@ -129,6 +129,8 @@ type FPAEnabled: Boolean; FAlexEnabled: Boolean; + FSendLock: TCriticalSection; // защита concurrent UDP sends + function DoCreateSocket: TSocket; procedure DoCloseSocket(var S: TSocket); function DoSendTo(S: TSocket; const Buf; BufLen: Integer; @@ -423,11 +425,13 @@ begin FIsTransmitting := False; FPAEnabled := True; FAlexEnabled := True; + FSendLock := TCriticalSection.Create; end; destructor THPSDRNetwork.Destroy; begin Disconnect; + FSendLock.Free; inherited; end; @@ -529,7 +533,12 @@ begin Addr.sin_family := AF_INET; Addr.sin_port := htons(DestPort); Addr.sin_addr := StrToNetAddr(DestIP); - Result := fpSendTo(S, @Buf, BufLen, 0, @Addr, SizeOf(Addr)) = BufLen; + FSendLock.Enter; + try + Result := fpSendTo(S, @Buf, BufLen, 0, @Addr, SizeOf(Addr)) = BufLen; + finally + FSendLock.Leave; + end; end; function THPSDRNetwork.DoRecvFrom(S: TSocket; var Buf; BufLen: Integer; @@ -767,19 +776,11 @@ end; procedure THPSDRNetwork.HandleMicData(const Buf: array of Byte; Len: Integer); var - Pkt: TMicDataPacket; - Sync: TMicSync; - M: TThreadMethod; + Pkt: TMicDataPacket; begin if not Assigned(FOnMic) then Exit; Move(Buf[0], Pkt, SizeOf(Pkt)); - Sync := TMicSync.Create(FOnMic, Pkt); - try - M := Sync.Execute; - TThread.Synchronize(nil, M); - finally - Sync.Free; - end; + FOnMic(Pkt); // прямой вызов из receive thread — TX обработка не касается UI end; // --------------------------------------------------------------------------- @@ -820,8 +821,14 @@ begin Result := 0; // TX relay при передаче + // Bit 27 ($08000000) = T/R relay для всех плат + // Bit 18 ($00040000) = TxRx Status для Orion MkII / Saturn / G2 if Transmitting then - Result := Result or $08000000; // ALEX_TX_RELAY + begin + Result := Result or $08000000; // ALEX_TX_RELAY bit27 + if IsOrion2 then + Result := Result or $00040000; // TxRx Status bit18 (ANAN-7/8000, Saturn) + end; // RX HPF (для ANAN-100/200) или BPF (для ANAN-7000) if IsOrion2 then @@ -876,15 +883,15 @@ var begin FillChar(Pkt, SizeOf(Pkt), 0); - // ANAN-7000/8000 имеют 2 ADC - if FDevice.BoardType in [4, 5] then // ORION=4, ORION2=5 + // ANAN-7000/8000/Saturn имеют 2 ADC + if FDevice.BoardType in [4, 5, 10] then // ORION=4, ORION2=5, SATURN=10 Pkt.NumADCs := 2 else Pkt.NumADCs := 1; - // Для ANGELIA/ORION/ORION2 DDC начинается с индекса 2 (DDC0/1 — PureSignal) + // Для ANGELIA/ORION/ORION2/SATURN DDC начинается с индекса 2 (DDC0/1 — PureSignal) // Для HERMES/HL2 — с 0 - if FDevice.BoardType in [3, 4, 5] then // ANGELIA=3, ORION=4, ORION2=5 + if FDevice.BoardType in [3, 4, 5, 10] then // ANGELIA=3, ORION=4, ORION2=5, SATURN=10 DDCBase := 2 else DDCBase := 0; @@ -960,11 +967,16 @@ begin Inc(FSeqHP); // Byte 4: Run | PTT - if FRunning then Buf[4] := HP_RUN; + if FRunning then + begin + Buf[4] := HP_RUN; + if FIsTransmitting then Buf[4] := Buf[4] or HP_PTT0; + end; - // DDC base: ORION/ORION2/ANGELIA начинают с DDC2 - IsOrion2 := (FDevice.BoardType = 5); // ORION-II = 5 - if FDevice.BoardType in [3, 4, 5] then // ANGELIA=3, ORION=4, ORION2=5 + // DDC base: ORION/ORION2/ANGELIA/SATURN начинают с DDC2 + // IsOrion2: платы с Alex BPF и TxRx Status bit18 (ORION_MK2=5, SATURN=10) + IsOrion2 := FDevice.BoardType in [5, 10]; // ORION_MK2=5, SATURN=10 + if FDevice.BoardType in [3, 4, 5, 10] then // ANGELIA=3, ORION=4, ORION_MK2=5, SATURN=10 DDCBase := 2 else DDCBase := 0; diff --git a/MainForm.pas b/MainForm.pas index 6ac9192..f80a302 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -26,7 +26,7 @@ uses StdCtrls, ExtCtrls, ComCtrls, Buttons, Menus, Math, Types, LCLIntf, LCLType, GraphType, HPSDRProtocol, HPSDRNetwork, - WDSPEngine, AudioOutput, + WDSPEngine, AudioOutput, AudioInput, IntfGraphics, FPImage, Settings, WebServer; @@ -150,8 +150,9 @@ type FSWRV: Double; FSupplyV: Double; FPLLLock: Boolean; + FHWPTT: Boolean; public - constructor Create(AForm: TObject; FW, SW, SV: Double; PLL: Boolean); + constructor Create(AForm: TObject; FW, SW, SV: Double; PLL, HWPTT: Boolean); procedure Execute; end; @@ -180,9 +181,12 @@ type FPendingBoardType: Integer; // BoardType устройства для подключения // ---- DSP + Audio ---- - FDSPEngine: TWDSPEngine; - FAudioOut: TAudioOutput; - FWDSPReady: Boolean; // True когда WDSP открыт и работает + FDSPEngine: TWDSPEngine; + FAudioOut: TAudioOutput; + FAudioIn: TAudioInput; + FAudioOutDevName: string; // текущее имя выходного устройства + FAudioInDevName: string; // текущее имя входного устройства (TX mic) + FWDSPReady: Boolean; // True когда WDSP открыт и работает // ---- State ---- FVfoA: Double; @@ -203,8 +207,11 @@ type FMarkerActive: Boolean; // True = линия видима FMarkerX: Integer; // X в пикселях (относительно ширины панели) FDriveLevel: Byte; + FPAMaxPower: Double; + FPABandCal: array[0..BAND_COUNT-1] of Double; // калибровка 38.8..100.0 на диапазон FRunning: Boolean; FTransmitting: Boolean; + FHWPTTActive: Boolean; // True = hardware PTT нажата FMuted: Boolean; FVolume: Integer; FLastSMeter: Double; @@ -223,16 +230,25 @@ type // ---- Spectrum ---- FSpectrumBuf: array[0..1023] of Single; FWfAvgBuf: array[0..1023] of Single; + FWaterfallBuf: array[0..1023] of Single; // WDSP waterfall pixout=1 data FSpectrumBitmap: TBitmap; FWfAGCEnabled: Boolean; FWfNFEnabled: Boolean; FWfHigh: Double; FWfLow: Double; + // Spectrum grid settings + FSpecRefLevel: Double; // dBm top of scale (e.g. -20) + FSpecRange: Double; // dB range (e.g. 110 → bottom = -130) + FSpecGridStep: Double; // grid step in dB (e.g. 10) FWaterfallBitmap:TBitmap; FWaterfallTemp: TBitmap; FSpectrumWidth: Integer; FSpectrumHeight: Integer; FWaterfallHeight:Integer; + // ---- Visibility settings ---- + FShowSpectrum: Boolean; + FShowWaterfall: Boolean; + FDisplayFPS: Integer; // таймер спектра: кадров в секунду (5..30) // ---- Splitter между спектром и водопадом ---- FSplitter: TPanel; // визуальная полоса-разделитель FSplitterDrag: Boolean; // True = идёт перетаскивание @@ -268,6 +284,7 @@ type FWebSyncBool: Boolean; FWebSyncM: TThreadMethod; FCurrentBand: Integer; // текущий активный диапазон 0..10 + FSettingsForm: TObject; // TSettingsForm (cast при использовании) FDevMAC: array[0..5] of Byte; // MAC подключённого трансивера FPendingDev: THPSDRDevice; // устройство ожидающее открытия WDSP FBandCache: array[0..CFG_BAND_COUNT-1] of TBandSettings; // кэш диапазонов @@ -283,8 +300,9 @@ type // ---- Toolbar ---- PanelToolbar: TPanel; - BtnDiscover: TFlatButton; + BtnDiscover: TFlatButton; BtnStartStop: TFlatButton; // START / STOP (connect+run в одном) + BtnSettings: TFlatButton; // открыть диалог настроек // ---- Left panel ---- PanelLeft: TPanel; @@ -397,10 +415,11 @@ type // DSP/Audio callbacks (вызываются из рабочих потоков) procedure OnAudioReady(const Left, Right: array of Single; Count: Integer); procedure OnSpectrumReady(const Pixels: array of Single; Count: Integer); + procedure OnWaterfallReady(const Pixels: array of Single; Count: Integer); // Public UI update (called from sync objects) procedure DoAddDevice(const Dev: THPSDRDevice; const Entry: string); - procedure DoUpdateStatus(FwdW, SWRV, SupplyV: Double; PLLLock: Boolean); + procedure DoUpdateStatus(FwdW, SWRV, SupplyV: Double; PLLLock, HWPTT: Boolean); procedure DoUpdateDDCSeq(DDCIdx: Integer; Seq: LongWord); procedure DoUpdateDDCSeqOrNoDevice(DDCIdx: Integer; Seq: LongWord); @@ -503,11 +522,18 @@ type procedure BtnWfAGCClick(Sender: TObject); procedure BtnWfNFClick(Sender: TObject); procedure BtnHidePanelClick(Sender: TObject); + procedure BtnSettingsClick(Sender: TObject); + procedure InvalidateGridCache; procedure PositionVfoOverlay; procedure OnModeFilterSelect(Mode: Integer; FilterBW: Integer); procedure PbSpectrumDblClick(Sender: TObject); procedure TrkDriveChange(Sender: TObject); procedure TrkVolumeChange(Sender: TObject); + function CalcDriveByte: Byte; + function ActiveTXFreqHz: Double; + procedure ApplyMOX(Active: Boolean); + procedure OnTXIQReady(const Buf: array of Double; Count: Integer); + procedure OnPASettingsChange(MaxPower: Double; const BandCal: array of Double); procedure BtnAGCModeClick(Sender: TObject); procedure TrkAGCChange(Sender: TObject); procedure PbFwdPowerPaint(Sender: TObject); @@ -527,6 +553,18 @@ type procedure SplitterMouseUp(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); + public + // Методы для SettingsForm — немедленное применение настроек + procedure ApplyDisplayParams(FFTSize, WinType, SpecDet, SpecAvgMode, + SpecAvgCount: Integer; SpecBackmult: Double); + procedure ApplyWaterfallParams(WfDet, WfAvgMode, WfAvgCount: Integer; + WfBackmult: Double); + procedure ApplyGridParams(RefLevel, Range, GridStep: Double); + procedure ApplyAudioDevice(DevIndex: Integer; const DevName: string); + procedure ApplyAudioInputDevice(DevIndex: Integer; const DevName: string); + procedure ApplyVisibility(ShowSpectrum, ShowWaterfall: Boolean); + procedure ApplyFPS(FPS: Integer); + published // Обработчики событий формы — должны быть в published для RTTI/LFM procedure FormCreate(Sender: TObject); @@ -542,6 +580,8 @@ var implementation +uses SettingsForm; + {$R *.lfm} // =========================================================================== @@ -581,7 +621,7 @@ begin end; constructor TStatusUISync.Create(AForm: TObject; - FW, SW, SV: Double; PLL: Boolean); + FW, SW, SV: Double; PLL, HWPTT: Boolean); begin inherited Create; FForm := AForm; @@ -589,11 +629,12 @@ begin FSWRV := SW; FSupplyV := SV; FPLLLock := PLL; + FHWPTT := HWPTT; end; procedure TStatusUISync.Execute; begin - TMainForm(FForm).DoUpdateStatus(FFwdW, FSWRV, FSupplyV, FPLLLock); + TMainForm(FForm).DoUpdateStatus(FFwdW, FSWRV, FSupplyV, FPLLLock, FHWPTT); end; constructor TDDCSeqSync.Create(AForm: TObject; Idx: Integer; Seq: LongWord); @@ -647,9 +688,13 @@ begin end; function TMainForm.MakeGlobalSettings: TGlobalSettings; +var i: Integer; begin Result.Volume := FVolume; - Result.DriveLevel := FDriveLevel; + Result.DriveLevel := TrkDrive.Position; + Result.PAMaxPower := FPAMaxPower; + for i := 0 to BAND_COUNT - 1 do + Result.PABandCal[i] := FPABandCal[i]; Result.ActiveVfo := FActiveVfo; Result.NRMode := BtnNR.Tag; Result.NBMode := BtnNB.Tag; @@ -661,6 +706,43 @@ begin Result.WfNFEnabled := FWfNFEnabled; Result.LastBand := FCurrentBand; Result.SampleRate := FSampleRate; + // Display settings + if FWDSPReady then + begin + Result.FFTSize := FDSPEngine.FFTSize; + Result.WindowType := FDSPEngine.WindowType; + Result.SpecDetector := FDSPEngine.SpecDetector; + Result.SpecAvgMode := FDSPEngine.SpecAvgMode; + Result.SpecAvgCount := FDSPEngine.SpecAvgCount; + Result.SpecBackmult := FDSPEngine.SpecBackmult; + Result.WfDetector := FDSPEngine.WfDetector; + Result.WfAvgMode := FDSPEngine.WfAvgMode; + Result.WfAvgCount := FDSPEngine.WfAvgCount; + Result.WfBackmult := FDSPEngine.WfBackmult; + end + else + begin + Result.FFTSize := 4096; + Result.WindowType := 5; + Result.SpecDetector := 0; + Result.SpecAvgMode := 3; + Result.SpecAvgCount := 2; + Result.SpecBackmult := 0.45; + Result.WfDetector := 2; + Result.WfAvgMode := 3; + Result.WfAvgCount := 2; + Result.WfBackmult := 0.45; + end; + Result.SpecRefLevel := FSpecRefLevel; + Result.SpecRange := FSpecRange; + Result.SpecGridStep := FSpecGridStep; + Result.AudioSampleRate := FAudioOut.SampleRate; + Result.ShowSpectrum := FShowSpectrum; + Result.ShowWaterfall := FShowWaterfall; + Result.DisplayFPS := FDisplayFPS; + // Audio device names + Result.AudioOutDevice := FAudioOutDevName; + Result.AudioInDevice := FAudioInDevName; end; procedure TMainForm.SaveCurrentBand; @@ -751,7 +833,7 @@ end; procedure TMainForm.FormCreate(Sender: TObject); var - bi_, StartupRate: Integer; + bi_, StartupRate, i: Integer; StartupVfoA, StartupVfoB: Double; begin FVfoA := 14200000; @@ -765,9 +847,11 @@ begin FCTun := False; FFilterBW := 2700; FSpecDrag := False; - FDriveLevel := 100; - FRunning := False; - FTransmitting := False; + FDriveLevel := 0; + FPAMaxPower := 100.0; + for i := 0 to BAND_COUNT - 1 do FPABandCal[i] := 100.0; + FRunning := False; + FTransmitting := False; FMuted := False; FVolume := 70; FCenterFreq := FVfoA; @@ -782,6 +866,11 @@ begin FWfAGCEnabled := False; FWfNFEnabled := False; FDevConnected := False; + // Spectrum grid settings defaults + FSpecRefLevel := -20.0; + FSpecRange := 110.0; + FSpecGridStep := 10.0; + FSettingsForm := nil; FillChar(FDevMAC, SizeOf(FDevMAC), 0); // Инициализируем кэш диапазонов умолчаниями @@ -847,7 +936,10 @@ begin FVfoOverlay := TVfoOverlay.Create(Self); FVfoOverlay.OnSelect := OnModeFilterSelect; FVfoOverlay.Visible := False; - FPanelHidden := False; + FPanelHidden := False; + FShowSpectrum := True; + FShowWaterfall := True; + FDisplayFPS := 20; FSplitterRatio := 0.40; // 40% спектр, 60% водопад по умолчанию FSplitterDrag := False; @@ -894,12 +986,15 @@ begin // Уменьшение с 1024 вдвое сокращает время построения downsampler в OpenChannel RXA // (~1300ms → ~650ms). Латентность: 512/48000 ≈ 10.7ms — допустимо для SDR. FDSPEngine := TWDSPEngine.Create(FSampleRate, 48000, 512); - FDSPEngine.OnAudio := OnAudioReady; - FDSPEngine.OnSpectrum := OnSpectrumReady; + FDSPEngine.OnAudio := OnAudioReady; + FDSPEngine.OnSpectrum := OnSpectrumReady; + FDSPEngine.OnWaterfall := OnWaterfallReady; + FDSPEngine.OnTXIQ := OnTXIQReady; - // Audio output — создаём объект сейчас, открываем после показа формы + // Audio output/input — создаём объекты сейчас, открываем после показа формы // (Pa_Initialize на Linux пишет в stderr до перехвата сигналов FPC) FAudioOut := TAudioOutput.Create(48000); + FAudioIn := TAudioInput.Create(48000); FMeterTimer := TTimer.Create(Self); FMeterTimer.Interval := 100; @@ -937,6 +1032,8 @@ begin FNetwork.Free; FAudioOut.Close; FAudioOut.Free; + FAudioIn.Close; + FAudioIn.Free; FDSPEngine.Close; FDSPEngine.Free; FSpectrumBitmap.Free; @@ -1034,6 +1131,7 @@ begin X := 4; BtnDiscover := MakeBtn(PanelToolbar, 'DISCOVER', X, 3, 80, BTN_H, BtnDiscoverClick); BtnStartStop := MakeBtn(PanelToolbar, 'START', X+84, 3, 76, BTN_H, BtnStartStopClick); + BtnSettings := MakeBtn(PanelToolbar, 'SETUP', X+164, 3, 66, BTN_H, BtnSettingsClick); // ---- Status bar ---- StatusBar1 := TStatusBar.Create(Self); @@ -1546,6 +1644,125 @@ begin TopOff := PanelSpanButtons.Height; // S-метр позиционируется отдельно в ResizeSMeter ResizeSMeter; + + // ---- Оба скрыты ---- + if (not FShowSpectrum) and (not FShowWaterfall) then + begin + PbSpectrum.Visible := False; + PbRuler.Visible := False; + PanelSplitter.Visible := False; + PbWaterfall.Visible := False; + FSpectrumWidth := RW; + FSpectrumHeight := 0; + FWaterfallHeight := 0; + PositionSpanOverlayButtons; + Exit; + end; + + // ---- Только спектр (водопад скрыт) ---- + if FShowSpectrum and (not FShowWaterfall) then + begin + AvailH := RH - TopOff - RULER_H; + if AvailH < MIN_SH then AvailH := MIN_SH; + SH := AvailH; + WH := 0; + PbSpectrum.SetBounds(0, TopOff, RW, SH); + PbSpectrum.Visible := True; + PbRuler.SetBounds(0, TopOff + SH, RW, RULER_H); + PbRuler.Visible := True; + PanelSplitter.Visible := False; + PbWaterfall.Visible := False; + PositionSpanOverlayButtons; + FSpectrumWidth := RW; + FSpectrumHeight := SH; + FWaterfallHeight := 0; + if (FSpectrumBitmap <> nil) and (RW > 0) and (SH > 0) then + begin + FSpectrumBitmap.SetSize(RW, SH); + FSpectrumBitmap.Canvas.Brush.Color := CLR_BG; + FSpectrumBitmap.Canvas.FillRect(Rect(0, 0, RW, SH)); + FreeAndNil(FSpecGradImg); + FGridBitmapW := 0; + FGridBitmapH := 0; + FSpPtsLen := 0; + end; + if (FWaterfallBitmap <> nil) then + begin + FWaterfallBitmap.SetSize(1, 1); + FWfBitmapW := 0; + FWfBitmapH := 0; + SetLength(FWfPixels, 0); + end; + if (FRulerBitmap <> nil) and (RW > 0) then + begin + FRulerBitmap.SetSize(RW, RULER_H); + FRulerLastFreq := -1.0; + FRulerLastVfo := -1.0; + end; + if RW > 0 then + begin + DrawSpectrum; + PbSpectrum.Invalidate; + PbRuler.Invalidate; + end; + Exit; + end; + + // ---- Только водопад (спектр скрыт): линейка под водопадом ---- + if (not FShowSpectrum) and FShowWaterfall then + begin + AvailH := RH - TopOff - RULER_H; + if AvailH < MIN_WH then AvailH := MIN_WH; + SH := 0; + WH := AvailH; + PbSpectrum.Visible := False; + PanelSplitter.Visible := False; + PbWaterfall.SetBounds(0, TopOff, RW, WH); + PbWaterfall.Visible := True; + PbRuler.SetBounds(0, TopOff + WH, RW, RULER_H); + PbRuler.Visible := True; + PositionSpanOverlayButtons; + FSpectrumWidth := RW; + FSpectrumHeight := 0; + FWaterfallHeight := WH; + if (FSpectrumBitmap <> nil) then + begin + FSpectrumBitmap.SetSize(1, 1); + FreeAndNil(FSpecGradImg); + FGridBitmapW := 0; + FGridBitmapH := 0; + FSpPtsLen := 0; + end; + if (FWaterfallBitmap <> nil) and (RW > 0) and (WH > 0) then + begin + FWaterfallBitmap.SetSize(RW, WH); + FWaterfallBitmap.Canvas.Brush.Color := clBlack; + FWaterfallBitmap.Canvas.FillRect(Rect(0, 0, RW, WH)); + FWfBitmapW := 0; + FWfBitmapH := 0; + SetLength(FWfPixels, 0); + end; + if (FRulerBitmap <> nil) and (RW > 0) then + begin + FRulerBitmap.SetSize(RW, RULER_H); + FRulerLastFreq := -1.0; + FRulerLastVfo := -1.0; + end; + if RW > 0 then + begin + DrawWaterfall; + PbWaterfall.Invalidate; + PbRuler.Invalidate; + end; + Exit; + end; + + // ---- Оба видимы: стандартный режим со сплиттером ---- + PbSpectrum.Visible := True; + PbRuler.Visible := True; + PanelSplitter.Visible := True; + PbWaterfall.Visible := True; + AvailH := RH - TopOff - RULER_H - SPLITTER_H; if AvailH < (MIN_SH + MIN_WH) then Exit; @@ -1564,8 +1781,7 @@ begin // Линейка частот — всегда прижата к низу спектра PbRuler.SetBounds(0, TopOff + SH, RW, RULER_H); // Сплиттер — между линейкой и водопадом - if PanelSplitter <> nil then - PanelSplitter.SetBounds(0, TopOff + SH + RULER_H, RW, SPLITTER_H); + PanelSplitter.SetBounds(0, TopOff + SH + RULER_H, RW, SPLITTER_H); // Водопад — под сплиттером PbWaterfall.SetBounds(0, TopOff + SH + RULER_H + SPLITTER_H, RW, WH); @@ -1814,12 +2030,21 @@ end; procedure TMainForm.ResetSpectrumBuf; var i: Integer; begin - for i := 0 to 1023 do FSpectrumBuf[i] := -130.0; - for i := 0 to 1023 do FWfAvgBuf[i] := -120.0; + for i := 0 to 1023 do FSpectrumBuf[i] := -130.0; + for i := 0 to 1023 do FWfAvgBuf[i] := -120.0; + for i := 0 to 1023 do FWaterfallBuf[i] := -120.0; FWfHigh := -50.0; FWfLow := -120.0; end; +procedure TMainForm.InvalidateGridCache; +// Сбрасываем кэш фона/сетки — перерисуется на следующем тике DrawSpectrum +begin + FGridBitmapW := 0; + FGridBitmapH := 0; + FSpectrumDirty := True; +end; + procedure TMainForm.FillDemoSpectrum; var i: Integer; @@ -2035,7 +2260,8 @@ begin if (W <= 0) or (H <= 0) then Exit; C := FSpectrumBitmap.Canvas; - DBmin := -130.0; DBmax := -20.0; + DBmax := FSpecRefLevel; + DBmin := FSpecRefLevel - FSpecRange; InvRange := 1.0 / (DBmax - DBmin); // ── 1. Фон+сетка из кэша ─────────────────────────────────────────────── @@ -2056,7 +2282,7 @@ begin GC.Font.Name := 'Courier New'; GC.Font.Color := TColor($00A8C9D9); GC.Brush.Style := bsClear; - dB := -40.0; + dB := DBmax - FSpecGridStep; while dB >= DBmin do begin Yp := Round((DBmax - dB) * InvRange * H); @@ -2064,7 +2290,7 @@ begin GC.Pen.Width := 1; GC.MoveTo(0, Yp); GC.LineTo(W-1, Yp); GC.TextOut(1, Yp - 9, Format('%4.0f', [dB])); - dB := dB - 10; + dB := dB - FSpecGridStep; end; for i := 0 to 8 do begin @@ -2206,14 +2432,31 @@ begin H := FWaterfallBitmap.Height; if (W <= 0) or (H <= 0) then Exit; - // ── EMA + Peak + NF — один цикл по 1024 бинам ───────────────────────── + // ── Источник данных водопада ───────────────────────────────────────────── + // При работе WDSP: FWaterfallBuf заполнен из pixout=1 (уже усреднённые WDSP данные) + // Без WDSP (demo): делаем EMA FSpectrumBuf → FWfAvgBuf PeakDB := -200.0; NFSum := 0.0; NFCount := 0; - for X := 0 to 1023 do + if FWDSPReady then begin - AvgDB := FWfAvgBuf[X] * (1.0 - ALPHA_SPEC) + FSpectrumBuf[X] * ALPHA_SPEC; - FWfAvgBuf[X] := AvgDB; - if AvgDB > PeakDB then PeakDB := AvgDB; - if AvgDB < FWfLow + 15.0 then begin NFSum := NFSum + AvgDB; Inc(NFCount); end; + // Используем WDSP waterfall данные напрямую + for X := 0 to 1023 do + begin + AvgDB := FWaterfallBuf[X]; + FWfAvgBuf[X] := AvgDB; // синхронизируем для AGC логики + if AvgDB > PeakDB then PeakDB := AvgDB; + if AvgDB < FWfLow + 15.0 then begin NFSum := NFSum + AvgDB; Inc(NFCount); end; + end; + end + else + begin + // Fallback: Pascal EMA от демо-спектра + for X := 0 to 1023 do + begin + AvgDB := FWfAvgBuf[X] * (1.0 - ALPHA_SPEC) + FSpectrumBuf[X] * ALPHA_SPEC; + FWfAvgBuf[X] := AvgDB; + if AvgDB > PeakDB then PeakDB := AvgDB; + if AvgDB < FWfLow + 15.0 then begin NFSum := NFSum + AvgDB; Inc(NFCount); end; + end; end; // ── AGC High/Low ──────────────────────────────────────────────────────── @@ -2789,10 +3032,16 @@ var NowTick: QWord; ElapsedTick: QWord; begin - // Синхронизируем размеры если PaintBox изменился (после ресайза) - if (PbSpectrum.Width <> FSpectrumWidth) or - (PbSpectrum.Height <> FSpectrumHeight) or - (FSpectrumWidth = 0) then + // Синхронизируем размеры если размер панели изменился (после ресайза). + // Важно: проверяем только видимые панели — когда спектр скрыт, + // PbSpectrum.Height может быть ненулевым при FSpectrumHeight=0, + // что вызвало бы сброс буфера водопада на каждом тике. + if (FSpectrumWidth = 0) or + (FShowSpectrum and ((PbSpectrum.Width <> FSpectrumWidth) or + (PbSpectrum.Height <> FSpectrumHeight))) or + (FShowWaterfall and not FShowSpectrum and + ((PbWaterfall.Width <> FSpectrumWidth) or + (PbWaterfall.Height <> FWaterfallHeight))) then ResizeSpectrumPanels; ResizeSMeter; if FSpectrumWidth <= 0 then Exit; @@ -2977,7 +3226,8 @@ begin else SWRV := 1.0; Sync := TStatusUISync.Create(Self, FwdW, SWRV, SupplyV, - (Status.StatusBits and HPS_PLL_LOCKED) <> 0); + (Status.StatusBits and HPS_PLL_LOCKED) <> 0, + (Status.StatusBits and HPS_PTT) <> 0); try M := Sync.Execute; TThread.Synchronize(nil, M); @@ -2986,7 +3236,7 @@ begin end; end; -procedure TMainForm.DoUpdateStatus(FwdW, SWRV, SupplyV: Double; PLLLock: Boolean); +procedure TMainForm.DoUpdateStatus(FwdW, SWRV, SupplyV: Double; PLLLock, HWPTT: Boolean); var PLLStr: string; begin @@ -2998,7 +3248,15 @@ begin StatusBar1.Panels[2].Text := Format('Supply: %.1fV | FWD: %.0fW | SWR: %.1f | %s', [SupplyV, FwdW, SWRV, PLLStr]); - // Panels[3] зарезервирована для RX stats (DDC seq/pkt count) + + // Hardware PTT (foot switch / mic PTT) — обнаружение фронта + if HWPTT <> FHWPTTActive then + begin + FHWPTTActive := HWPTT; + // Hardware PTT включает/выключает передачу только если BtnMOX не нажата + if not BtnMOX.Active then + ApplyMOX(HWPTT); + end; end; procedure TMainForm.OnDDCIQCB(DDCIndex: Integer; const Data: TDDCIQPacket); @@ -3034,7 +3292,8 @@ end; procedure TMainForm.OnMicPacketCB(const Data: TMicDataPacket); begin - // TODO: WDSP TXA + if FTransmitting and FWDSPReady then + FDSPEngine.PushTXMicSamples16(Data.Samples, 64); end; // =========================================================================== @@ -3147,8 +3406,10 @@ begin end; FWDSPReady := False; FDSPEngine := TWDSPEngine.Create(ASampleRate, 48000, 512); - FDSPEngine.OnAudio := OnAudioReady; - FDSPEngine.OnSpectrum := OnSpectrumReady; + FDSPEngine.OnAudio := OnAudioReady; + FDSPEngine.OnSpectrum := OnSpectrumReady; + FDSPEngine.OnWaterfall := OnWaterfallReady; + FDSPEngine.OnTXIQ := OnTXIQReady; end; procedure TMainForm.BtnDiscoverClick(Sender: TObject); @@ -3297,6 +3558,7 @@ var GenPkt: TGeneralPacket; DUCPkt: TDUCSpecificPacket; G_Settings: TGlobalSettings; + i: Integer; begin BtnStartStop.Enabled := True; @@ -3312,8 +3574,14 @@ begin FDevConnected := True; FSettings.LoadDevice(FDevMAC, G_Settings, FBandCache); FVolume := G_Settings.Volume; - FDriveLevel := G_Settings.DriveLevel; FActiveVfo := G_Settings.ActiveVfo; + // PA settings + FPAMaxPower := G_Settings.PAMaxPower; + for i := 0 to BAND_COUNT - 1 do + FPABandCal[i] := G_Settings.PABandCal[i]; + // Slider position (0..100) and calibrated drive byte + TrkDrive.Position := EnsureRange(G_Settings.DriveLevel, 0, 100); + FDriveLevel := CalcDriveByte; FCurrentBand := G_Settings.LastBand; // SampleRate — глобальный, загружаем до RestoreBand if G_Settings.SampleRate > 0 then @@ -3327,6 +3595,53 @@ begin BtnANF.Tag := Ord(G_Settings.ANFEnabled); UpdateANFButton; FWfAGCEnabled := G_Settings.WfAGCEnabled; StyleButton(BtnWfAGC, FWfAGCEnabled); FWfNFEnabled := G_Settings.WfNFEnabled; StyleButton(BtnWfNF, FWfNFEnabled); + FShowSpectrum := G_Settings.ShowSpectrum; + FShowWaterfall := G_Settings.ShowWaterfall; + if G_Settings.DisplayFPS > 0 then + ApplyFPS(G_Settings.DisplayFPS); + // Настройки дисплея и сетки + if G_Settings.FFTSize > 0 then + begin + FSpecRefLevel := G_Settings.SpecRefLevel; + FSpecRange := G_Settings.SpecRange; + if G_Settings.SpecGridStep > 0 then FSpecGridStep := G_Settings.SpecGridStep; + InvalidateGridCache; + if FWDSPReady then + begin + FDSPEngine.SetFFTParams(G_Settings.FFTSize, G_Settings.WindowType); + FDSPEngine.SetSpectrumDisplay(G_Settings.SpecDetector, G_Settings.SpecAvgMode, + G_Settings.SpecAvgCount, G_Settings.SpecBackmult); + FDSPEngine.SetWaterfallDisplay(G_Settings.WfDetector, G_Settings.WfAvgMode, + G_Settings.WfAvgCount, G_Settings.WfBackmult); + end; + // Аудио устройство вывода + FAudioOut.Close; + if G_Settings.AudioSampleRate > 0 then + FAudioOut.SampleRate := G_Settings.AudioSampleRate; + if G_Settings.AudioOutDevice <> '' then + begin + FAudioOut.DeviceIndex := FAudioOut.FindDeviceByName(G_Settings.AudioOutDevice); + FAudioOutDevName := G_Settings.AudioOutDevice; + end + else + begin + FAudioOut.DeviceIndex := -1; + FAudioOutDevName := ''; + end; + FAudioOut.Open; + // Аудио устройство ввода (TX mic) + if G_Settings.AudioInDevice <> '' then + begin + FAudioIn.DeviceIndex := FAudioIn.FindDeviceByName(G_Settings.AudioInDevice); + FAudioInDevName := G_Settings.AudioInDevice; + FAudioIn.Open; + end + else + begin + FAudioIn.DeviceIndex := -1; + FAudioInDevName := ''; + end; + end; RestoreBand(FCurrentBand); StatusBar1.Panels[0].Text := 'IP: ' + FNetwork.Device.IPAddress; @@ -4117,9 +4432,14 @@ begin end; end; -procedure TMainForm.BtnMOXClick(Sender: TObject); +function TMainForm.ActiveTXFreqHz: Double; begin - FTransmitting := not FTransmitting; + if FActiveVfo = 0 then Result := FVfoA else Result := FVfoB; +end; + +procedure TMainForm.ApplyMOX(Active: Boolean); +begin + FTransmitting := Active; if FTransmitting then begin BtnMOX.ClrNorm := TColor($00000044); @@ -4128,7 +4448,19 @@ begin BtnMOX.Active := True; end else StyleButton(BtnMOX, False); - if FWDSPReady then FDSPEngine.SetTXRun(FTransmitting); + if FWDSPReady then + FDSPEngine.SetTXRun(FTransmitting); + if FNetwork.Connected and FNetwork.Running then + begin + FNetwork.UpdateState(FCenterFreq, ActiveTXFreqHz, FDriveLevel, + FTransmitting, True, True); + FNetwork.SendFullHP; + end; +end; + +procedure TMainForm.BtnMOXClick(Sender: TObject); +begin + ApplyMOX(not FTransmitting); end; procedure TMainForm.BtnMuteClick(Sender: TObject); @@ -4784,11 +5116,62 @@ end; procedure TMainForm.TrkDriveChange(Sender: TObject); begin - FDriveLevel := Round(TrkDrive.Position / 100.0 * 255); + FDriveLevel := CalcDriveByte; if FWDSPReady then FDSPEngine.SetDriveLevel(TrkDrive.Position / 100.0); end; +function TMainForm.CalcDriveByte: Byte; +var Cal: Double; +begin + if (FCurrentBand >= 0) and (FCurrentBand < BAND_COUNT) and (FPABandCal[FCurrentBand] > 0.0) then + Cal := FPABandCal[FCurrentBand] + else + Cal := 100.0; + Result := Round(TrkDrive.Position / 100.0 * Cal / 100.0 * 255.0); +end; + +procedure TMainForm.OnTXIQReady(const Buf: array of Double; Count: Integer); +var + IData: array[0..239] of Integer; + QData: array[0..239] of Integer; + Offset, n, i: Integer; +begin + if not FNetwork.Connected then Exit; + Offset := 0; + while Offset < Count do + begin + FillChar(IData, SizeOf(IData), 0); + FillChar(QData, SizeOf(QData), 0); + n := Count - Offset; + if n > 240 then n := 240; + for i := 0 to n - 1 do + begin + IData[i] := Round(Buf[(Offset + i) * 2] * 8388607.0); + QData[i] := Round(Buf[(Offset + i) * 2 + 1] * 8388607.0); + end; + FNetwork.SendDUCIQ(IData, QData); + Inc(Offset, 240); + end; +end; + +procedure TMainForm.OnPASettingsChange(MaxPower: Double; + const BandCal: array of Double); +var i: Integer; +begin + FPAMaxPower := MaxPower; + for i := 0 to BAND_COUNT - 1 do + begin + if i <= High(BandCal) then + FPABandCal[i] := BandCal[i] + else + FPABandCal[i] := 100.0; + end; + FDriveLevel := CalcDriveByte; + if FNetwork.Connected and FNetwork.Running then + FNetwork.UpdateState(FCenterFreq, FCenterFreq, FDriveLevel, FTransmitting, True, True); +end; + procedure TMainForm.TrkVolumeChange(Sender: TObject); begin FVolume := TrkVolume.Position; @@ -4903,6 +5286,146 @@ begin FSpectrumDirty := True; end; +procedure TMainForm.OnWaterfallReady(const Pixels: array of Single; + Count: Integer); +var + i, N: Integer; +begin + // Заполняем буфер водопада из WDSP pixout=1. + // Вызывается из DSP-потока — Single-запись атомарна. + N := Min(Count, 1024); + for i := 0 to N - 1 do + FWaterfallBuf[i] := Pixels[i]; +end; + +// =========================================================================== +// Settings apply methods (вызываются из TSettingsForm) +// =========================================================================== + +procedure TMainForm.ApplyDisplayParams(FFTSize, WinType, SpecDet, SpecAvgMode, + SpecAvgCount: Integer; SpecBackmult: Double); +begin + if FWDSPReady then + FDSPEngine.SetFFTParams(FFTSize, WinType); + if FWDSPReady then + FDSPEngine.SetSpectrumDisplay(SpecDet, SpecAvgMode, SpecAvgCount, SpecBackmult); +end; + +procedure TMainForm.ApplyWaterfallParams(WfDet, WfAvgMode, WfAvgCount: Integer; + WfBackmult: Double); +begin + if FWDSPReady then + FDSPEngine.SetWaterfallDisplay(WfDet, WfAvgMode, WfAvgCount, WfBackmult); +end; + +procedure TMainForm.ApplyGridParams(RefLevel, Range, GridStep: Double); +begin + FSpecRefLevel := RefLevel; + FSpecRange := Range; + if GridStep > 0 then FSpecGridStep := GridStep; + InvalidateGridCache; +end; + +procedure TMainForm.ApplyAudioDevice(DevIndex: Integer; const DevName: string); +begin + if FAudioOut.IsOpen then + FAudioOut.Close; + FAudioOut.DeviceIndex := DevIndex; + FAudioOutDevName := DevName; + try + if not FAudioOut.Open then + StatusBar1.Panels[3].Text := 'Audio out: ' + FAudioOut.LastError + else + StatusBar1.Panels[3].Text := 'Audio out: ' + IfThen(DevName = '', 'Default', DevName); + except + on E: Exception do + StatusBar1.Panels[3].Text := 'Audio out err: ' + E.Message; + end; +end; + +procedure TMainForm.ApplyAudioInputDevice(DevIndex: Integer; const DevName: string); +begin + if FAudioIn.IsOpen then + FAudioIn.Close; + FAudioIn.DeviceIndex := DevIndex; + FAudioInDevName := DevName; + if DevIndex >= 0 then + begin + try + if not FAudioIn.Open then + StatusBar1.Panels[3].Text := 'Mic: ' + FAudioIn.LastError + else + StatusBar1.Panels[3].Text := 'Mic: ' + DevName; + except + on E: Exception do + StatusBar1.Panels[3].Text := 'Mic err: ' + E.Message; + end; + end + else + StatusBar1.Panels[3].Text := 'Mic: none'; +end; + +procedure TMainForm.ApplyVisibility(ShowSpectrum, ShowWaterfall: Boolean); +begin + FShowSpectrum := ShowSpectrum; + FShowWaterfall := ShowWaterfall; + ResizeSpectrumPanels; +end; + +procedure TMainForm.ApplyFPS(FPS: Integer); +begin + if FPS < 1 then FPS := 1; + if FPS > 60 then FPS := 60; + FDisplayFPS := FPS; + FSpectrumTimer.Interval := 1000 div FPS; +end; + +procedure TMainForm.BtnSettingsClick(Sender: TObject); +var + SF: TSettingsForm; +begin + if not Assigned(FSettingsForm) then + begin + SF := TSettingsForm.Create(Self); + FSettingsForm := SF; + SF.OnDisplayChange := ApplyDisplayParams; + SF.OnWaterfallChange := ApplyWaterfallParams; + SF.OnGridChange := ApplyGridParams; + SF.OnAudioDevChange := ApplyAudioDevice; + SF.OnAudioInDevChange := ApplyAudioInputDevice; + SF.OnVisibilityChange := ApplyVisibility; + SF.OnFPSChange := ApplyFPS; + SF.OnPAChange := OnPASettingsChange; + end; + SF := TSettingsForm(FSettingsForm); + SF.LoadPASettings(FPAMaxPower, FPABandCal); + // Перечисляем PA устройства + SF.RefreshAudioDevices(FAudioOut, FAudioIn); + SF.LoadVisibility(FShowSpectrum, FShowWaterfall); + SF.LoadFPS(FDisplayFPS); + // Загружаем текущие значения + if FWDSPReady then + SF.LoadValues( + FDSPEngine.FFTSize, + FDSPEngine.WindowType, + FDSPEngine.SpecDetector, + FDSPEngine.SpecAvgMode, + FDSPEngine.SpecAvgCount, + FDSPEngine.SpecBackmult, + FDSPEngine.WfDetector, + FDSPEngine.WfAvgMode, + FDSPEngine.WfAvgCount, + FDSPEngine.WfBackmult, + FSpecRefLevel, FSpecRange, FSpecGridStep, + FAudioOutDevName, FAudioInDevName) + else + SF.LoadValues( + 4096, 5, 0, 3, 2, 0.45, + 2, 3, 2, 0.45, + FSpecRefLevel, FSpecRange, FSpecGridStep, + FAudioOutDevName, FAudioInDevName); + SF.Show; +end; procedure TMainForm.AfterShowTick(Sender: TObject); begin diff --git a/Settings.pas b/Settings.pas index 917f71e..5fa8166 100644 --- a/Settings.pas +++ b/Settings.pas @@ -48,6 +48,32 @@ type WindowTop: Integer; WindowWidth: Integer; WindowHeight: Integer; + // --- Display settings --- + FFTSize: Integer; // 1024,2048,4096,8192,16384 + WindowType: Integer; // 0=Rect,1=BH4,2=Hann,3=FlatTop,4=Hamming,5=Kaiser,6=BH7 + SpecDetector: Integer; // 0=Peak,1=Rosenfell,2=Average,3=Sample,4=RMS + SpecAvgMode: Integer; // 0=None,1=Recursive,2=TimeWindow,3=LogRecursive + SpecAvgCount: Integer; // frames to average + SpecBackmult: Double; // 0.0..1.0 recursive averaging multiplier + WfDetector: Integer; // waterfall detector + WfAvgMode: Integer; // waterfall average mode + WfAvgCount: Integer; // waterfall average count + WfBackmult: Double; // waterfall backmult + SpecRefLevel: Double; // dBm reference (top of scale), e.g. -20 + SpecRange: Double; // dB range, e.g. 110 (bottom = ref - range) + SpecGridStep: Double; // grid step in dB, e.g. 10 + // --- Audio settings --- + AudioOutDevice: string; // PortAudio output device name ('' = default) + AudioSampleRate: Integer; // PortAudio stream sample rate (e.g. 48000) + AudioInDevice: string; // PortAudio input device name for TX mic ('' = none) + // --- Visibility settings --- + ShowSpectrum: Boolean; // True = draw spectrum on main form + ShowWaterfall: Boolean; // True = draw waterfall on main form + // --- Display FPS --- + DisplayFPS: Integer; // spectrum/waterfall timer fps (5..30) + // --- PA (Power Amplifier) settings --- + PAMaxPower: Double; // максимальная выходная мощность, Вт (5..200) + PABandCal: array[0..CFG_BAND_COUNT-1] of Double; // калибровка на диапазон 38.8..100.0 end; TSettingsManager = class @@ -61,9 +87,11 @@ type function JI(O: TJSONObject; const K: string; Def: Integer): Integer; function JD(O: TJSONObject; const K: string; Def: Double): Double; function JB(O: TJSONObject; const K: string; Def: Boolean): Boolean; + function JS(O: TJSONObject; const K: string; const Def: string): string; procedure JW(O: TJSONObject; const K: string; V: Integer); overload; procedure JW(O: TJSONObject; const K: string; V: Double); overload; procedure JW(O: TJSONObject; const K: string; V: Boolean); overload; + procedure JWS(O: TJSONObject; const K: string; const V: string); public constructor Create(const FilePath: string = SETTINGS_FILE); destructor Destroy; override; @@ -110,12 +138,35 @@ begin end; class procedure TSettingsManager.DefaultGlobal(out G: TGlobalSettings); +var i: Integer; begin FillChar(G, SizeOf(G), 0); G.Volume := 70; G.DriveLevel := 50; G.ActiveVfo := 0; G.NRMode := 0; G.NBMode := 0; G.SNBEnabled := False; G.ANFEnabled := False; G.AGCSlope := 0; G.AGCHangThreshold := 100; G.LastBand := 5; G.SampleRate := 192000; + G.PAMaxPower := 100.0; + for i := 0 to CFG_BAND_COUNT-1 do G.PABandCal[i] := 100.0; + // Display defaults + G.FFTSize := 4096; + G.WindowType := 5; // Kaiser + G.SpecDetector := 0; // Peak + G.SpecAvgMode := 3; // Log Recursive + G.SpecAvgCount := 2; + G.SpecBackmult := 0.45; + G.WfDetector := 2; // Average + G.WfAvgMode := 3; // Log Recursive + G.WfAvgCount := 2; + G.WfBackmult := 0.45; + G.SpecRefLevel := -20.0; + G.SpecRange := 110.0; + G.SpecGridStep := 10.0; + G.AudioOutDevice := ''; + G.AudioSampleRate := 48000; + G.AudioInDevice := ''; + G.ShowSpectrum := True; + G.ShowWaterfall := True; + G.DisplayFPS := 20; end; constructor TSettingsManager.Create(const FilePath: string); @@ -226,6 +277,17 @@ begin O.Add(K, V); end; +function TSettingsManager.JS(O: TJSONObject; const K: string; const Def: string): string; +var D: TJSONData; +begin D := O.Find(K); if D<>nil then try Result:=D.AsString; except Result:=Def; end else Result:=Def; end; + +procedure TSettingsManager.JWS(O: TJSONObject; const K: string; const V: string); +var Idx: Integer; +begin + Idx := O.IndexOfName(K); if Idx >= 0 then O.Delete(Idx); + O.Add(K, V); +end; + function TSettingsManager.LoadDevice(const MAC: array of Byte; out G: TGlobalSettings; var Bands: array of TBandSettings): Boolean; @@ -249,6 +311,32 @@ begin G.WfNFEnabled := JB(GObj,'wf_nf_enabled',False); G.LastBand := JI(GObj,'last_band',5); G.SampleRate := JI(GObj,'sample_rate',192000); + // Display settings + G.FFTSize := JI(GObj,'fft_size',4096); + G.WindowType := JI(GObj,'win_type',5); + G.SpecDetector := JI(GObj,'spec_detector',0); + G.SpecAvgMode := JI(GObj,'spec_avg_mode',3); + G.SpecAvgCount := JI(GObj,'spec_avg_count',2); + G.SpecBackmult := JD(GObj,'spec_backmult',0.45); + G.WfDetector := JI(GObj,'wf_detector',2); + G.WfAvgMode := JI(GObj,'wf_avg_mode',3); + G.WfAvgCount := JI(GObj,'wf_avg_count',2); + G.WfBackmult := JD(GObj,'wf_backmult',0.45); + G.SpecRefLevel := JD(GObj,'spec_ref_level',-20.0); + G.SpecRange := JD(GObj,'spec_range',110.0); + G.SpecGridStep := JD(GObj,'spec_grid_step',10.0); + // Audio settings + G.AudioOutDevice := JS(GObj,'audio_out_device',''); + G.AudioSampleRate := JI(GObj,'audio_sample_rate',48000); + G.AudioInDevice := JS(GObj,'audio_in_device',''); + // Visibility settings + G.ShowSpectrum := JB(GObj,'show_spectrum',True); + G.ShowWaterfall := JB(GObj,'show_waterfall',True); + G.DisplayFPS := JI(GObj,'display_fps',20); + // PA settings + G.PAMaxPower := JD(GObj,'pa_max_power',100.0); + for i := 0 to CFG_BAND_COUNT-1 do + G.PABandCal[i] := JD(GObj,'pa_band_cal_'+IntToStr(i),100.0); for i := 0 to CFG_BAND_COUNT-1 do begin @@ -268,7 +356,7 @@ end; procedure TSettingsManager.SaveGlobal(const MAC: array of Byte; const G: TGlobalSettings); -var O: TJSONObject; +var O: TJSONObject; i: Integer; begin O := GetGlobalObj(GetDevObj(MacToStr(MAC))); JW(O,'volume',G.Volume); JW(O,'drive_level',G.DriveLevel); @@ -283,6 +371,32 @@ begin JW(O,'wf_nf_enabled',G.WfNFEnabled); JW(O,'last_band',G.LastBand); JW(O,'sample_rate',G.SampleRate); + // Display settings + JW(O,'fft_size',G.FFTSize); + JW(O,'win_type',G.WindowType); + JW(O,'spec_detector',G.SpecDetector); + JW(O,'spec_avg_mode',G.SpecAvgMode); + JW(O,'spec_avg_count',G.SpecAvgCount); + JW(O,'spec_backmult',G.SpecBackmult); + JW(O,'wf_detector',G.WfDetector); + JW(O,'wf_avg_mode',G.WfAvgMode); + JW(O,'wf_avg_count',G.WfAvgCount); + JW(O,'wf_backmult',G.WfBackmult); + JW(O,'spec_ref_level',G.SpecRefLevel); + JW(O,'spec_range',G.SpecRange); + JW(O,'spec_grid_step',G.SpecGridStep); + // Audio settings + JWS(O,'audio_out_device',G.AudioOutDevice); + JW(O,'audio_sample_rate',G.AudioSampleRate); + JWS(O,'audio_in_device',G.AudioInDevice); + // Visibility settings + JW(O,'show_spectrum',G.ShowSpectrum); + JW(O,'show_waterfall',G.ShowWaterfall); + JW(O,'display_fps',G.DisplayFPS); + // PA settings + JW(O,'pa_max_power',G.PAMaxPower); + for i := 0 to CFG_BAND_COUNT-1 do + JW(O,'pa_band_cal_'+IntToStr(i),G.PABandCal[i]); end; procedure TSettingsManager.SaveBand(const MAC: array of Byte; BandIdx: Integer; diff --git a/SettingsForm.pas b/SettingsForm.pas index 3564001..9546596 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -30,6 +30,10 @@ uses FlatButton, AudioOutput, AudioInput; type + // PA: MaxPower в Вт, BandCal[0..10] — калибровка 38.8..100.0 на диапазон + TOnPASettingsChange = procedure(MaxPower: Double; + const BandCal: array of Double) of object; + TOnDisplayParamChange = procedure(FFTSize, WindowType, SpecDetector, SpecAvgMode, SpecAvgCount: Integer; SpecBackmult: Double) of object; TOnWaterfallParamChange = procedure(WfDetector, WfAvgMode, WfAvgCount: Integer; @@ -92,10 +96,15 @@ type FChkShowWaterfall: TCheckBox; FCmbFPS: TComboBox; + // ---- PA Settings tab controls ---- + FEdMaxPower: TSpinEdit; + FEdBandCal: array[0..10] of TFloatSpinEdit; + // ---- Close button ---- FBtnClose: TFlatButton; // ---- Callbacks ---- + FOnPAChange: TOnPASettingsChange; FOnDisplayChange: TOnDisplayParamChange; FOnWaterfallChange: TOnWaterfallParamChange; FOnGridChange: TOnGridParamChange; @@ -111,6 +120,7 @@ type procedure BuildAudioTab; procedure BuildDisplayTab; procedure BuildRX1Tab; + procedure BuildPATab; procedure ApplyTheme; // Helpers @@ -139,6 +149,8 @@ type procedure OnTXDevChange(Sender: TObject); procedure OnVisibilityChkChange(Sender: TObject); procedure OnFPSCmbChange(Sender: TObject); + procedure OnMaxPowerChange(Sender: TObject); + procedure OnBandCalChange(Sender: TObject); procedure BtnCloseClick(Sender: TObject); procedure FireDisplayChange; @@ -171,7 +183,10 @@ type procedure LoadVisibility(ShowSpectrum, ShowWaterfall: Boolean); // Загрузить текущий FPS (без fire события) procedure LoadFPS(FPS: Integer); + // Загрузить PA настройки (без fire событий) + procedure LoadPASettings(MaxPower: Double; const BandCal: array of Double); + property OnPAChange: TOnPASettingsChange read FOnPAChange write FOnPAChange; property OnDisplayChange: TOnDisplayParamChange read FOnDisplayChange write FOnDisplayChange; property OnWaterfallChange: TOnWaterfallParamChange read FOnWaterfallChange write FOnWaterfallChange; property OnGridChange: TOnGridParamChange read FOnGridChange write FOnGridChange; @@ -216,6 +231,9 @@ const FPS_VALUES: array[0..5] of Integer = (5, 10, 15, 20, 25, 30); FPS_NAMES: array[0..5] of string = ('5 fps','10 fps','15 fps','20 fps','25 fps','30 fps'); + PA_BAND_NAMES: array[0..10] of string = + ('160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m'); + { TSettingsForm } constructor TSettingsForm.Create(AOwner: TComponent); @@ -312,6 +330,7 @@ begin BuildAudioTab; BuildDisplayTab; + BuildPATab; // Close button FBtnClose := TFlatButton.Create(Self); @@ -1030,6 +1049,160 @@ begin end; end; +// --------------------------------------------------------------------------- +// BuildPATab — вкладка PA Settings: MaxPower + per-band calibration +// --------------------------------------------------------------------------- + +procedure TSettingsForm.BuildPATab; +const + LBL_W = 46; // ширина метки диапазона + ED_W = 72; // ширина SpinEdit (с кнопками) + COL_W = LBL_W + ED_W + 8; // ширина одной пары + COLS = 4; // колонок на строку + ROW_H = 32; // высота строки (spin edits чуть выше plain edit) + GRP_PAD = 12; // отступ внутри группы + GRP_TOP = 24; // высота caption группы + ROWS = 3; // строк для 11 диапазонов (ceil(11/4)) + + function MakeFloatSpin(AParent: TWinControl; ALeft, ATop: Integer; + AChange: TNotifyEvent): TFloatSpinEdit; + var E: TFloatSpinEdit; + begin + E := TFloatSpinEdit.Create(Self); + E.Parent := AParent; + E.SetBounds(ALeft, ATop, ED_W, BTN_H + 2); + E.Color := CLR_PANEL; + E.Font.Color := CLR_TEXT; + E.Font.Name := 'Courier New'; + E.Font.Size := 8; + E.MinValue := 38.8; + E.MaxValue := 100.0; + E.Increment := 0.1; + E.DecimalPlaces := 1; + E.Value := 100.0; + E.OnChange := AChange; + Result := E; + end; + +var + Grp: TGroupBox; + Sep: TPanel; + Lbl: TLabel; + Spin: TSpinEdit; + i, r, c, X, Y: Integer; +begin + // ── Top padding ─────────────────────────────────────────────────────────── + Sep := TPanel.Create(Self); + Sep.Parent := FTabPA; + Sep.BevelOuter := bvNone; + Sep.Color := CLR_BG; + Sep.Align := alTop; + Sep.Height := 10; + + // ── Group Power & Calibration ───────────────────────────────────────────── + Grp := TGroupBox.Create(Self); + Grp.Parent := FTabPA; + Grp.Caption := 'Power & Calibration'; + Grp.Color := CLR_GROUP; + Grp.Font.Color := CLR_ACCENT; + Grp.Font.Name := 'Courier New'; + Grp.Font.Size := 8; + Grp.Align := alTop; + Grp.Height := GRP_TOP + ROW_H + 20 + ROWS * ROW_H + GRP_PAD + 16; + Grp.AutoSize := False; + + // Max Power строка + Lbl := TLabel.Create(Self); + Lbl.Parent := Grp; + Lbl.Caption := 'Max Power (W):'; + Lbl.SetBounds(GRP_PAD, GRP_TOP + 6, 120, 18); + Lbl.Font.Color := CLR_TEXTDIM; + Lbl.Font.Name := 'Courier New'; + Lbl.Font.Size := 8; + + Spin := TSpinEdit.Create(Self); + Spin.Parent := Grp; + Spin.SetBounds(GRP_PAD + 124, GRP_TOP, ED_W, BTN_H + 2); + Spin.Color := CLR_PANEL; + Spin.Font.Color := CLR_TEXT; + Spin.Font.Name := 'Courier New'; + Spin.Font.Size := 8; + Spin.MinValue := 5; + Spin.MaxValue := 200; + Spin.Increment := 1; + Spin.Value := 100; + Spin.OnChange := OnMaxPowerChange; + FEdMaxPower := Spin; + + // Метка для калибровки + Lbl := TLabel.Create(Self); + Lbl.Parent := Grp; + Lbl.Caption := 'Calibration per band (38.8 ... 100.0):'; + Lbl.SetBounds(GRP_PAD, GRP_TOP + ROW_H + 8, 280, 18); + Lbl.Font.Color := CLR_TEXTDIM; + Lbl.Font.Name := 'Courier New'; + Lbl.Font.Size := 8; + + // Grid 4 columns × 3 rows for 11 bands + for i := 0 to 10 do + begin + r := i div COLS; + c := i mod COLS; + X := GRP_PAD + c * (COL_W + 4); + Y := GRP_TOP + ROW_H + 26 + r * ROW_H; + + Lbl := TLabel.Create(Self); + Lbl.Parent := Grp; + Lbl.Caption := PA_BAND_NAMES[i] + ':'; + Lbl.SetBounds(X, Y + 5, LBL_W, 18); + Lbl.Font.Color := CLR_TEXTDIM; + Lbl.Font.Name := 'Courier New'; + Lbl.Font.Size := 8; + + FEdBandCal[i] := MakeFloatSpin(Grp, X + LBL_W, Y, OnBandCalChange); + FEdBandCal[i].Tag := i; + end; +end; + +procedure TSettingsForm.LoadPASettings(MaxPower: Double; + const BandCal: array of Double); +var i, n: Integer; +begin + FLoading := True; + try + FEdMaxPower.Value := Round(EnsureRange(MaxPower, 5.0, 200.0)); + n := Min(10, High(BandCal)); + for i := 0 to n do + FEdBandCal[i].Value := EnsureRange(BandCal[i], 38.8, 100.0); + finally + FLoading := False; + end; +end; + +procedure TSettingsForm.OnMaxPowerChange(Sender: TObject); +var + BandCal: array[0..10] of Double; + i: Integer; +begin + if FLoading then Exit; + if not Assigned(FOnPAChange) then Exit; + for i := 0 to 10 do + BandCal[i] := FEdBandCal[i].Value; + FOnPAChange(FEdMaxPower.Value, BandCal); +end; + +procedure TSettingsForm.OnBandCalChange(Sender: TObject); +var + BandCal: array[0..10] of Double; + i: Integer; +begin + if FLoading then Exit; + if not Assigned(FOnPAChange) then Exit; + for i := 0 to 10 do + BandCal[i] := FEdBandCal[i].Value; + FOnPAChange(FEdMaxPower.Value, BandCal); +end; + procedure TSettingsForm.BtnCloseClick(Sender: TObject); begin Close; diff --git a/WDSPEngine.pas b/WDSPEngine.pas index 466da27..6e7226a 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -103,6 +103,9 @@ const IQ_QUEUE_SIZE = 64; // кол-во слотов (степень двойки для AND-маски) IQ_PKT_MAXBYTES = 1428; // max байт IQ данных в пакете (238*6) + // TX mic ring buffer — должен быть степенью двойки + TX_MIC_RING = 8192; + type // Пакет в очереди между сетевым и DSP потоком TIQQueueItem = record @@ -118,6 +121,11 @@ type Count: Integer) of object; TOnSpectrumReady = procedure(const Pixels: array of Single; Count: Integer) of object; + // TX IQ callback — вызывается из TTXDSPThread при готовности блока + // Buf: interleaved [I0,Q0,I1,Q1,...] doubles, Count — кол-во IQ пар + TOnTXIQReady = procedure(const Buf: array of Double; Count: Integer) of object; + TOnWaterfallReady = procedure(const Pixels: array of Single; + Count: Integer) of object; { TWDSPEngine } TWDSPEngine = class @@ -140,8 +148,8 @@ type FRXAccQ: array of Double; FRXAccPos: Integer; - // Snapshot буфер для Spectrum0 - FSnapBuf: array of Double; + // Копия входных IQ для Spectrum0 (до fexchange0, который данные in-place перезаписывает) + FSpecBuf: array of Double; // Аудио выходные буферы — аллоцируем один раз FOutL: array of Single; FOutR: array of Single; @@ -152,14 +160,36 @@ type FQueueTail: Integer; // читает DSP поток FQueueSem: PRTLEvent; // сигнал: есть новые данные (RTLEvent) FDSPRunning: Boolean; - // Double буфер для Spectrum0 (принимает PDouble, не PdINREAL) - FSpecBuf: array of Double; // Double буфер для Spectrum0 (PDouble) - FSpectrumPixels: array[0..SPECTRUM_PIXELS - 1] of Single; - FFlp: array[0..0] of Integer; // for SetAnalyzer flp parameter + FSpectrumPixels: array[0..SPECTRUM_PIXELS - 1] of Single; + FWaterfallPixels: array[0..SPECTRUM_PIXELS - 1] of Single; + FFlp: array[0..0] of Integer; // for SetAnalyzer flp parameter - FOnAudio: TOnAudioReady; - FOnSpectrum: TOnSpectrumReady; + // Настройки анализатора + FFFTSize: Integer; + FWindowType: Integer; + FSpecDetector: Integer; + FSpecAvgMode: Integer; + FSpecAvgCount: Integer; + FSpecBackmult: Double; + FWfDetector: Integer; + FWfAvgMode: Integer; + FWfAvgCount: Integer; + FWfBackmult: Double; + + FOnAudio: TOnAudioReady; + FOnSpectrum: TOnSpectrumReady; + FOnWaterfall: TOnWaterfallReady; + FOnTXIQ: TOnTXIQReady; + + // TX mic ring buffer (lock-free: один writer — receive thread, + // один reader — TX DSP thread) + FTXMicRing: array[0..TX_MIC_RING-1] of Double; + FTXMicHead: Integer; // пишет receive thread + FTXMicTail: Integer; // читает TX DSP thread + FTXMicSem: PRTLEvent; // сигнал: есть новые mic сэмплы + FTXThread: TThread; + FTXActive: Boolean; FMode: Integer; FSMeter: Double; @@ -187,11 +217,13 @@ type procedure PushIQItemToDSP(const Item: TIQQueueItem); procedure OpenAnalyzer; procedure CloseAnalyzer; + procedure ApplyAnalyzerSettings; procedure ApplyNRState; procedure ApplyNBState; procedure ApplySNBState; procedure ApplyANFState; procedure ApplyNoiseFilterState; + procedure ProcessTXBlock; // вызывается из TTXDSPThread public constructor Create(SampleRate: Integer = 192000; @@ -236,6 +268,15 @@ type procedure SetMicGain(GainDB: Double); procedure SetTXRun(Run: Boolean); + // TX mic вход — вызывается из receive thread (thread-safe lock-free) + // Src: 16-bit big-endian hardware mic samples, N — кол-во сэмплов + procedure PushTXMicSamples16(const Src: array of SmallInt; N: Integer); + + // Настройки анализатора (применяются немедленно если открыт) + procedure SetFFTParams(FFTSize, WinType: Integer); + procedure SetSpectrumDisplay(Detector, AvgMode, AvgCount: Integer; Backmult: Double); + procedure SetWaterfallDisplay(Detector, AvgMode, AvgCount: Integer; Backmult: Double); + // Spectrum — вызывать из таймера (~20 fps) procedure UpdateSpectrum; procedure GetSpectrumData(var Pixels: array of Single; var Count: Integer); @@ -259,12 +300,69 @@ type property SNBEnabled: Boolean read FSNBEnabled; property LastError: string read FLastError; property SMeter: Double read FSMeter; - property OnAudio: TOnAudioReady read FOnAudio write FOnAudio; - property OnSpectrum: TOnSpectrumReady read FOnSpectrum write FOnSpectrum; + property FFTSize: Integer read FFFTSize; + property WindowType: Integer read FWindowType; + property SpecDetector: Integer read FSpecDetector; + property SpecAvgMode: Integer read FSpecAvgMode; + property SpecAvgCount: Integer read FSpecAvgCount; + property SpecBackmult: Double read FSpecBackmult; + property WfDetector: Integer read FWfDetector; + property WfAvgMode: Integer read FWfAvgMode; + property WfAvgCount: Integer read FWfAvgCount; + property WfBackmult: Double read FWfBackmult; + property OnAudio: TOnAudioReady read FOnAudio write FOnAudio; + property OnSpectrum: TOnSpectrumReady read FOnSpectrum write FOnSpectrum; + property OnWaterfall: TOnWaterfallReady read FOnWaterfall write FOnWaterfall; + property OnTXIQ: TOnTXIQReady read FOnTXIQ write FOnTXIQ; + property TXActive: Boolean read FTXActive; end; implementation +// =========================================================================== +// TX DSP поток — читает mic ring buffer, вызывает fexchange0(TXA_CHAN), +// сигнализирует о готовых IQ данных через FOnTXIQ callback. +// Аналог tx_thread в piHPSDR/transmitter.c +// =========================================================================== +type + TTXDSPThread = class(TThread) + private + FEngine: TWDSPEngine; + protected + procedure Execute; override; + public + constructor Create(AEngine: TWDSPEngine); + end; + +constructor TTXDSPThread.Create(AEngine: TWDSPEngine); +begin + FEngine := AEngine; + FreeOnTerminate := False; + inherited Create(False); +end; + +procedure TTXDSPThread.Execute; +var + Avail: Integer; +begin + while not Terminated do + begin + RTLEventWaitFor(FEngine.FTXMicSem, 50); + if Terminated then Break; + if not FEngine.FTXActive then Continue; + + // Обрабатываем все накопившиеся блоки + repeat + Avail := (FEngine.FTXMicHead - FEngine.FTXMicTail + TX_MIC_RING) + and (TX_MIC_RING - 1); + if Avail >= FEngine.FAudioBufSize then + FEngine.ProcessTXBlock + else + Break; + until Terminated; + end; +end; + // =========================================================================== // DSP поток — обрабатывает IQ пакеты из очереди // Сетевой поток только кладёт пакеты, этот поток занимается DSP @@ -367,6 +465,17 @@ begin FSNBEnabled := False; FANFEnabled := False; FRXAccPos := 0; + // Analyzer defaults + FFFTSize := 4096; + FWindowType := 5; // Kaiser + FSpecDetector := 0; // Peak + FSpecAvgMode := 3; // Log Recursive + FSpecAvgCount := 2; + FSpecBackmult := 0.45; + FWfDetector := 2; // Average + FWfAvgMode := 3; // Log Recursive + FWfAvgCount := 2; + FWfBackmult := 0.45; SetLength(FRXIn, FBufSize * 2); // in_size пар @ FSampleRate (4096*2) SetLength(FRXOut, FAudioBufSize * 2); // out_size пар @ FAudioRate (1024*2) @@ -374,10 +483,9 @@ begin SetLength(FTXOut, FBufSize * 2); SetLength(FRXAccI, FBufSize); SetLength(FRXAccQ, FBufSize); - SetLength(FSnapBuf, FBufSize * 2); + SetLength(FSpecBuf, FBufSize * 2); SetLength(FOutL, FAudioBufSize); SetLength(FOutR, FAudioBufSize); - SetLength(FSpecBuf, FBufSize * 2); // Очередь и DSP поток FQueueHead := 0; @@ -385,12 +493,22 @@ begin FDSPRunning := False; FQueueSem := RTLEventCreate; FDSPThread := nil; + + // TX инициализация + FTXMicHead := 0; + FTXMicTail := 0; + FTXActive := False; + FTXThread := nil; + FTXMicSem := RTLEventCreate; + FillChar(FTXMicRing, SizeOf(FTXMicRing), 0); end; destructor TWDSPEngine.Destroy; begin + SetTXRun(False); // останавливаем TX поток если запущен Close; RTLEventDestroy(FQueueSem); + RTLEventDestroy(FTXMicSem); inherited; end; @@ -400,10 +518,7 @@ end; procedure TWDSPEngine.OpenAnalyzer; var - Success: Integer; - MaxW: Integer; - Ovrlp: Integer; - FRAME_RATE: Integer; + Success: Integer; begin if FAnalyzerOpen then Exit; @@ -411,30 +526,41 @@ begin XCreateAnalyzer(DISP_ID, @Success, 16384, 1, 1, nil); if Success <> 0 then Exit; - FRAME_RATE := 20; - // max_w: WDSP аллоцирует ring-buffer и FFT планы на max_w сэмплов. - // Формула: fft_size + min(0.1*samplerate, 0.1*fft_size*fps) - // = 4096 + min(19200, 8192) = 12288 → 1700ms инициализации! - // Реально нам нужен только один FFT-кадр без усреднения по времени. - // MaxW = sz = 4096 — минимально допустимое значение (должно быть ≥ sz). - // piHPSDR использует именно sz без добавки keep_time. - MaxW := 4096; - Ovrlp := 0; - FFlp[0] := 0; - SetAnalyzer(DISP_ID, 1, 1, 1, @FFlp[0], 4096, FAudioBufSize, 1, 14.0, - Ovrlp, 0, 0.0, 0.0, SPECTRUM_PIXELS, 1, 0, 0.0, 0.0, MaxW); - - SetDisplayDetectorMode(DISP_ID, 0, DETECTOR_MODE_AVERAGE); - SetDisplayAverageMode(DISP_ID, 0, AVERAGE_MODE_LOG_RECURSIVE); - SetDisplayNumAverage(DISP_ID, 0, Max(2, Trunc(FRAME_RATE * 0.12))); - SetDisplayAvBackmult(DISP_ID, 0, 0.45); - SetDisplaySampleRate(DISP_ID, FSampleRate); - SetDisplayNormOneHz(DISP_ID, 0, 0); + ApplyAnalyzerSettings; FAnalyzerOpen := True; end; +procedure TWDSPEngine.ApplyAnalyzerSettings; +// Вызывается из OpenAnalyzer и при изменении FFT/window/detector/avg параметров +var + MaxW: Integer; +begin + // MaxW = FFTSize — минимально допустимо (piHPSDR approach) + MaxW := FFFTSize; + + // n_pixout=2: pixout=0 — спектр, pixout=1 — водопад + SetAnalyzer(DISP_ID, 2, 1, 1, @FFlp[0], FFFTSize, FAudioBufSize, FWindowType, 14.0, + 0, 0, 0.0, 0.0, SPECTRUM_PIXELS, 1, 0, 0.0, 0.0, MaxW); + + // Спектр (pixout=0) + SetDisplayDetectorMode(DISP_ID, 0, FSpecDetector); + SetDisplayAverageMode(DISP_ID, 0, FSpecAvgMode); + SetDisplayNumAverage(DISP_ID, 0, FSpecAvgCount); + SetDisplayAvBackmult(DISP_ID, 0, FSpecBackmult); + SetDisplayNormOneHz(DISP_ID, 0, 0); + + // Водопад (pixout=1) + SetDisplayDetectorMode(DISP_ID, 1, FWfDetector); + SetDisplayAverageMode(DISP_ID, 1, FWfAvgMode); + SetDisplayNumAverage(DISP_ID, 1, FWfAvgCount); + SetDisplayAvBackmult(DISP_ID, 1, FWfBackmult); + SetDisplayNormOneHz(DISP_ID, 1, 0); + + SetDisplaySampleRate(DISP_ID, FSampleRate); +end; + procedure TWDSPEngine.CloseAnalyzer; begin if not FAnalyzerOpen then Exit; @@ -501,13 +627,16 @@ begin SetRXAPanelRun(RXA_CHAN, 1); SetChannelState(RXA_CHAN, 1, 0); + // TXA: mic @ FAudioRate → IQ @ FSampleRate (DUC rate) + // out_size = FAudioBufSize * FSampleRate/FAudioRate = FBufSize (4096 @ 192kHz) + // FTXOut уже имеет размер FBufSize*2 — подходит идеально OpenChannel( TXA_CHAN, FAudioBufSize, FAudioBufSize, FAudioRate, FAudioRate, - FAudioRate, + FSampleRate, 1, 0, 0.010, 0.010, 0.010, 0.010, @@ -668,18 +797,19 @@ begin FRXIn[i * 2 + 1] := FRXAccQ[i]; end; - // Копируем входные IQ ДО fexchange0 для Spectrum0 - // fexchange0 in-place перезапишет FRXIn выходными данными @ 48kHz + // Копируем IQ в FSpecBuf ДО fexchange0: fexchange0 in-place перезаписывает FRXIn if FAnalyzerOpen then - for i := 0 to FBufSize * 2 - 1 do - FSpecBuf[i] := FRXIn[i]; // Single→Double, входные IQ @ 192kHz + begin + Move(FRXIn[0], FSpecBuf[0], FBufSize * 2 * SizeOf(Double)); + end; // DSP обработка Err := 0; fexchange0(RXA_CHAN, @FRXIn[0], @FRXOut[0], @Err); - // Spectrum0 с входными данными (скопированными до fexchange0) - if FAnalyzerOpen then + // Подаём RX IQ в анализатор только когда не передаём. + // Во время TX анализатор кормит ProcessTXBlock (TX сигнал на той же частоте). + if FAnalyzerOpen and not FTXActive then Spectrum0(1, DISP_ID, 0, 0, @FSpecBuf[0]); // S-meter: читаем ОДИН раз в DSP-колбэке и кешируем в FSMeter. @@ -973,11 +1103,92 @@ end; procedure TWDSPEngine.SetTXRun(Run: Boolean); begin - if not FInitialized then Exit; + FTXActive := Run; + if Run then - SetChannelState(TXA_CHAN, 1, 0) // run, no delay - else - SetChannelState(TXA_CHAN, 0, 1); // stop, with slew + begin + if FInitialized then + SetChannelState(TXA_CHAN, 1, 0); // run, no delay + // Сброс mic ring buffer и запуск TX потока + FTXMicHead := 0; + FTXMicTail := 0; + if not Assigned(FTXThread) then + begin + FTXThread := TTXDSPThread.Create(Self); + FTXThread.Priority := tpHighest; + end; + end else begin + if FInitialized then + SetChannelState(TXA_CHAN, 0, 1); // stop, with slew + // Останавливаем TX поток + if Assigned(FTXThread) then + begin + FTXThread.Terminate; + RTLEventSetEvent(FTXMicSem); + FTXThread.WaitFor; + FreeAndNil(FTXThread); + end; + FTXMicHead := 0; + FTXMicTail := 0; + end; +end; + +procedure TWDSPEngine.ProcessTXBlock; +// Вызывается из TTXDSPThread — обрабатывает один блок FAudioBufSize mic сэмплов +// через WDSP TXA, выдаёт FBufSize IQ пар @ FSampleRate в FOnTXIQ callback +var + i, tail: Integer; + Err: Integer; +begin + if not FInitialized then Exit; + + // Читаем FAudioBufSize сэмплов из ring buffer → FTXIn + tail := FTXMicTail; + for i := 0 to FAudioBufSize - 1 do + begin + FTXIn[i * 2] := FTXMicRing[tail]; + FTXIn[i * 2 + 1] := 0.0; // Q = 0, mic — монофонический сигнал + tail := (tail + 1) and (TX_MIC_RING - 1); + end; + FTXMicTail := tail; // Атомарно обновляем tail (один writer — TX thread) + + Err := 0; + fexchange0(TXA_CHAN, @FTXIn[0], @FTXOut[0], @Err); + + // TX сигнал → анализатор: спектр/водопад показывают переданный сигнал + // FTXOut: FBufSize IQ пар @ FSampleRate — тот же формат что и FSpecBuf + if FAnalyzerOpen then + Spectrum0(1, DISP_ID, 0, 0, @FTXOut[0]); + + // FTXOut содержит FBufSize IQ пар @ FSampleRate (interleaved double) + if Assigned(FOnTXIQ) then + FOnTXIQ(FTXOut, FBufSize); +end; + +procedure TWDSPEngine.PushTXMicSamples16(const Src: array of SmallInt; N: Integer); +// Вызывается из receive thread — lock-free, кладёт сэмплы в ring buffer +// 16-bit big-endian samples из аппаратного микрофона +var + i, cnt, newHead: Integer; + S: SmallInt; +begin + cnt := N; + if cnt > Length(Src) then cnt := Length(Src); + + for i := 0 to cnt - 1 do + begin + // Аппаратный mic приходит в big-endian, нужно swap байт + S := SmallInt(((Src[i] and $FF) shl 8) or ((Src[i] shr 8) and $FF)); + newHead := (FTXMicHead + 1) and (TX_MIC_RING - 1); + if newHead <> FTXMicTail then // буфер не полон + begin + FTXMicRing[FTXMicHead] := S / 32768.0; + FTXMicHead := newHead; + end; + // если буфер полон — сэмпл отбрасывается (overrun) + end; + + RTLEventSetEvent(FTXMicSem); // будим TX поток end; // --------------------------------------------------------------------------- @@ -985,9 +1196,12 @@ end; // --------------------------------------------------------------------------- procedure TWDSPEngine.UpdateSpectrum; -// Вызывается из таймера главного потока (~20fps) -// Spectrum0 уже вызван в сетевом потоке после каждого fexchange0 -// Здесь только читаем готовые пиксели +// Вызывается из таймера главного потока (настраиваемый FPS). +// Здесь вызываем Spectrum0 — WDSP берёт снапшот из своего кольцевого +// буфера (который fexchange0 непрерывно заполняет из DSP-потока). +// Это обеспечивает равномерный FPS независимо от размера FFT: +// при FFT=16384 WDSP использует уже накопленные данные из overlap-save +// буфера, а не ждёт следующего полного блока. var PixBuf: array[0..SPECTRUM_PIXELS - 1] of Single; Flag: Integer; @@ -995,15 +1209,27 @@ var begin if not FInitialized or not FAnalyzerOpen then Exit; + // Спектр (pixout=0) Flag := 0; GetPixels(DISP_ID, 0, @PixBuf[0], @Flag); - if Flag = 0 then Exit; // нет нового кадра — ждём следующего тика + if Flag <> 0 then + begin + for i := 0 to SPECTRUM_PIXELS - 1 do + FSpectrumPixels[i] := PixBuf[i]; + if Assigned(FOnSpectrum) then + FOnSpectrum(FSpectrumPixels, SPECTRUM_PIXELS); + end; - for i := 0 to SPECTRUM_PIXELS - 1 do - FSpectrumPixels[i] := PixBuf[i]; - - if Assigned(FOnSpectrum) then - FOnSpectrum(FSpectrumPixels, SPECTRUM_PIXELS); + // Водопад (pixout=1) + Flag := 0; + GetPixels(DISP_ID, 1, @PixBuf[0], @Flag); + if Flag <> 0 then + begin + for i := 0 to SPECTRUM_PIXELS - 1 do + FWaterfallPixels[i] := PixBuf[i]; + if Assigned(FOnWaterfall) then + FOnWaterfall(FWaterfallPixels, SPECTRUM_PIXELS); + end; end; procedure TWDSPEngine.GetSpectrumData(var Pixels: array of Single; @@ -1024,4 +1250,50 @@ begin Result := FSMeter; end; +// --------------------------------------------------------------------------- +// Настройки анализатора (применяются немедленно) +// --------------------------------------------------------------------------- + +procedure TWDSPEngine.SetFFTParams(FFTSize, WinType: Integer); +begin + // Ограничения: FFT size должен быть >= bf_sz (FAudioBufSize = 1024) + // и степенью двойки, не превышать m_size=16384 (из XCreateAnalyzer) + if FFTSize < FAudioBufSize then FFTSize := FAudioBufSize; + if FFTSize > 16384 then FFTSize := 16384; + if WinType < 0 then WinType := 0; + if WinType > 6 then WinType := 6; + FFFTSize := FFTSize; + FWindowType := WinType; + if FAnalyzerOpen then + ApplyAnalyzerSettings; +end; + +procedure TWDSPEngine.SetSpectrumDisplay(Detector, AvgMode, AvgCount: Integer; + Backmult: Double); +begin + FSpecDetector := Detector; + FSpecAvgMode := AvgMode; + FSpecAvgCount := AvgCount; + FSpecBackmult := Backmult; + if not FAnalyzerOpen then Exit; + SetDisplayDetectorMode(DISP_ID, 0, FSpecDetector); + SetDisplayAverageMode(DISP_ID, 0, FSpecAvgMode); + SetDisplayNumAverage(DISP_ID, 0, FSpecAvgCount); + SetDisplayAvBackmult(DISP_ID, 0, FSpecBackmult); +end; + +procedure TWDSPEngine.SetWaterfallDisplay(Detector, AvgMode, AvgCount: Integer; + Backmult: Double); +begin + FWfDetector := Detector; + FWfAvgMode := AvgMode; + FWfAvgCount := AvgCount; + FWfBackmult := Backmult; + if not FAnalyzerOpen then Exit; + SetDisplayDetectorMode(DISP_ID, 1, FWfDetector); + SetDisplayAverageMode(DISP_ID, 1, FWfAvgMode); + SetDisplayNumAverage(DISP_ID, 1, FWfAvgCount); + SetDisplayAvBackmult(DISP_ID, 1, FWfBackmult); +end; + end. diff --git a/doc/WDSP Guide, Rev 1.29.pdf b/doc/WDSP Guide, Rev 1.29.pdf new file mode 100644 index 0000000..d93d8f3 Binary files /dev/null and b/doc/WDSP Guide, Rev 1.29.pdf differ diff --git a/doc/openHPSDR Ethernet Protocol v4.3.docx b/doc/openHPSDR Ethernet Protocol v4.3.docx new file mode 100644 index 0000000..7dba40c Binary files /dev/null and b/doc/openHPSDR Ethernet Protocol v4.3.docx differ diff --git a/ewsdr.lpi b/ewsdr.lpi index 9d69e34..b262ee0 100644 --- a/ewsdr.lpi +++ b/ewsdr.lpi @@ -99,10 +99,18 @@ + + + + + + + +