diff --git a/BUILD.MD b/BUILD.MD index 47101cc..bedeca0 100644 --- a/BUILD.MD +++ b/BUILD.MD @@ -3,4 +3,8 @@ lazbuild -B --ws=qt6 ewsdr.lpr # RUN Build Windows lazbuild -B --ws=win32 ewsdr.lpr # RUN Build macOS -lazbuild -B --ws=cocoa ewsdr.lpr \ No newline at end of file +lazbuild -B --ws=cocoa ewsdr.lpr + +# Optional DMR AMBE+2 adapter (requires mbelib development files) +cmake -S native/dmr -B build/dmr -DCMAKE_BUILD_TYPE=Release +cmake --build build/dmr diff --git a/CATAdapter.pas b/CATAdapter.pas index a6af93a..ca6ed52 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_WFM) then Exit; + if (FSyncInt < 0) or (FSyncInt > MODE_DMR) then Exit; FController.SetMode(FSyncInt); end; diff --git a/CATEngine.pas b/CATEngine.pas index 7c8c697..7b9fc44 100644 --- a/CATEngine.pas +++ b/CATEngine.pas @@ -35,8 +35,8 @@ 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; - CAT_MODE_COUNT = 11; + MODE_DIGU = 8; MODE_DIGL = 9; MODE_WFM = 10; MODE_DMR = 11; + CAT_MODE_COUNT = 12; // FM repeater offset directions — зеркало FMRepeater.RPT_* (значения должны // совпадать: контроллер хранит FFMRptDir в этих же кодах). @@ -391,6 +391,7 @@ begin MODE_CWU: Result := 3; MODE_FM: Result := 4; MODE_WFM: Result := 4; // treated as FM + MODE_DMR: Result := 4; // DMR RF transport is narrow FM MODE_AM: Result := 5; MODE_SAM: Result := 5; // treated as AM MODE_DIGU: Result := 6; // Kenwood FSK @@ -1792,7 +1793,7 @@ end; function TCATEngine.ZZML: string; // ZZML: mode list (returns all mode names) -begin Result := 'LSB USB DSB CWL CWU FM AM SAM DIGUDIGLWFM '; end; +begin Result := 'LSB USB DSB CWL CWU FM AM SAM DIGUDIGLWFM DMR '; end; function TCATEngine.ZZMN(const s: string): string; // ZZMN: mode name — returns mode name for given mode number. @@ -1803,7 +1804,7 @@ var k: Integer; 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[8]:='DIGU'; names[9]:='DIGL'; names[10]:='WFM'; names[11]:='DMR'; if (Length(s) = 1) or (Length(s) = 2) then begin k := EnsureRange(StrToIntDef(s,0),0,CAT_MODE_COUNT-1); Result := names[k]; diff --git a/ChannelsForm.pas b/ChannelsForm.pas index 13e0831..2293564 100644 --- a/ChannelsForm.pas +++ b/ChannelsForm.pas @@ -18,9 +18,9 @@ uses const // Копии из MainForm: нужны для выпадающих списков - CH_MODE_COUNT = 11; + CH_MODE_COUNT = 12; CH_MODE_NAMES: array[0..CH_MODE_COUNT-1] of string = - ('LSB','USB','DSB','CWL','CWU','FM','AM','SAM','DIGU','DIGL','WFM'); + ('LSB','USB','DSB','CWL','CWU','FM','AM','SAM','DIGU','DIGL','WFM','DMR'); CH_CTCSS_COUNT = 38; CH_CTCSS_NAMES: array[0..CH_CTCSS_COUNT-1] of string = ( diff --git a/DMRBindings.pas b/DMRBindings.pas new file mode 100644 index 0000000..809c96c --- /dev/null +++ b/DMRBindings.pas @@ -0,0 +1,159 @@ +unit DMRBindings; + +{$IFDEF FPC} + {$MODE Delphi} +{$ENDIF} + +interface + +uses + Classes, SysUtils, DynLibs; + +const + DMR_ABI_VERSION = 1; + DMR_AMBE_BITS = 96; + DMR_PCM_SAMPLES = 160; + +type + TDMRMbeResult = packed record + ErrorsC0: LongInt; + ErrorsTotal: LongInt; + end; + PDMRMbeResult = ^TDMRMbeResult; + +function DMRLoad: Boolean; +procedure DMRUnload; +function DMRLoaded: Boolean; +function DMRLastError: string; + +function DMRMbeCreate(UVQuality: Integer): Pointer; +procedure DMRMbeDestroy(Decoder: Pointer); +procedure DMRMbeReset(Decoder: Pointer); +function DMRMbeDecode(Decoder: Pointer; Bits: PByte; PCM: PSingle; + ResultInfo: PDMRMbeResult): Integer; + +implementation + +type + TAbiVersion = function: LongWord; cdecl; + TMbeCreate = function(UVQuality: LongInt): Pointer; cdecl; + TMbeDestroy = procedure(Decoder: Pointer); cdecl; + TMbeReset = procedure(Decoder: Pointer); cdecl; + TMbeDecode = function(Decoder: Pointer; Bits: PByte; PCM: PSingle; + ResultInfo: Pointer): LongInt; cdecl; + +var + GLib: TLibHandle = dynlibs.NilHandle; + GLastError: string = ''; + GAbiVersion: TAbiVersion = nil; + GMbeCreate: TMbeCreate = nil; + GMbeDestroy: TMbeDestroy = nil; + GMbeReset: TMbeReset = nil; + GMbeDecode: TMbeDecode = nil; + +function Symbol(const Name: PChar): Pointer; +begin + Result := GetProcedureAddress(GLib, Name); + if Result = nil then + GLastError := 'Missing DMR symbol: ' + string(Name); +end; + +function DMRLoad: Boolean; +const + {$IFDEF WINDOWS} + Names: array[0..0] of string = ('ewsdr_dmr.dll'); + {$ELSE} + {$IFDEF DARWIN} + Names: array[0..0] of string = ('libewsdr_dmr.dylib'); + {$ELSE} + Names: array[0..1] of string = ('libewsdr_dmr.so', 'libewsdr_dmr.so.1'); + {$ENDIF} + {$ENDIF} +var + i: Integer; + LocalName: string; +begin + if GLib <> dynlibs.NilHandle then Exit(True); + GLastError := ''; + for i := Low(Names) to High(Names) do + begin + LocalName := ExtractFilePath(ParamStr(0)) + Names[i]; + GLib := LoadLibrary(LocalName); + if GLib = dynlibs.NilHandle then GLib := LoadLibrary(Names[i]); + if GLib <> dynlibs.NilHandle then Break; + end; + if GLib = dynlibs.NilHandle then + begin + GLastError := 'Optional EWSDR DMR library not found'; + Exit(False); + end; + + GAbiVersion := TAbiVersion(Symbol('ewsdr_dmr_abi_version')); + GMbeCreate := TMbeCreate(Symbol('ewsdr_dmr_mbe_create')); + GMbeDestroy := TMbeDestroy(Symbol('ewsdr_dmr_mbe_destroy')); + GMbeReset := TMbeReset(Symbol('ewsdr_dmr_mbe_reset')); + GMbeDecode := TMbeDecode(Symbol('ewsdr_dmr_mbe_decode')); + if not Assigned(GAbiVersion) or not Assigned(GMbeCreate) or + not Assigned(GMbeDestroy) or not Assigned(GMbeReset) or + not Assigned(GMbeDecode) then + begin + DMRUnload; + Exit(False); + end; + if GAbiVersion() <> DMR_ABI_VERSION then + begin + GLastError := Format('Unsupported EWSDR DMR ABI: %d', [GAbiVersion()]); + DMRUnload; + Exit(False); + end; + Result := True; +end; + +procedure DMRUnload; +begin + GAbiVersion := nil; + GMbeCreate := nil; + GMbeDestroy := nil; + GMbeReset := nil; + GMbeDecode := nil; + if GLib <> dynlibs.NilHandle then + begin + UnloadLibrary(GLib); + GLib := dynlibs.NilHandle; + end; +end; + +function DMRLoaded: Boolean; +begin + Result := GLib <> dynlibs.NilHandle; +end; + +function DMRLastError: string; +begin + Result := GLastError; +end; + +function DMRMbeCreate(UVQuality: Integer): Pointer; +begin + if Assigned(GMbeCreate) then Result := GMbeCreate(UVQuality) + else Result := nil; +end; + +procedure DMRMbeDestroy(Decoder: Pointer); +begin + if Assigned(GMbeDestroy) and (Decoder <> nil) then GMbeDestroy(Decoder); +end; + +procedure DMRMbeReset(Decoder: Pointer); +begin + if Assigned(GMbeReset) and (Decoder <> nil) then GMbeReset(Decoder); +end; + +function DMRMbeDecode(Decoder: Pointer; Bits: PByte; PCM: PSingle; + ResultInfo: PDMRMbeResult): Integer; +begin + if not Assigned(GMbeDecode) then Exit(-1); + Result := GMbeDecode(Decoder, Bits, PCM, ResultInfo); +end; + +end. diff --git a/DMRDecoder.pas b/DMRDecoder.pas new file mode 100644 index 0000000..39382c2 --- /dev/null +++ b/DMRDecoder.pas @@ -0,0 +1,404 @@ +unit DMRDecoder; + +{ + DMR 4FSK receive front-end. + + Input is 48 kHz discriminator audio from the WDSP FM demodulator. The DSP + callback only copies complete blocks into a bounded ring; symbol timing and + sync detection run in TDMRDecoderThread and can never stall the WDSP thread. + + This first layer deliberately stops at burst sync. The following layer will + pass aligned dibits to the trimmed dsd-fme DMR/FEC/AMBE core. +} + +{$IFDEF FPC} + {$MODE Delphi} +{$ENDIF} + +interface + +uses + Classes, SysUtils, SyncObjs, Math, DMRBindings; + +const + DMR_INPUT_RATE = 48000; + DMR_SYMBOL_RATE = 4800; + DMR_SAMPLES_PER_SYM = DMR_INPUT_RATE div DMR_SYMBOL_RATE; + DMR_RING_SIZE = 65536; // >1.3 s at 48 kHz, power of two + +type + TDMRSyncKind = ( + dskNone, + dskBSData, dskBSVoice, + dskMSData, dskMSVoice, + dskDirectTS1Data, dskDirectTS1Voice, + dskDirectTS2Data, dskDirectTS2Voice + ); + + TDMRStatus = record + Enabled: Boolean; + Synced: Boolean; + SyncKind: TDMRSyncKind; + Inverted: Boolean; + SyncCount: QWord; + DroppedSamples: QWord; + SignalRMS: Single; + LastSyncAgeMs: QWord; + VocoderAvailable: Boolean; + end; + + TDMRDecoder = class; + + TDMRDecoderThread = class(TThread) + private + FOwner: TDMRDecoder; + protected + procedure Execute; override; + public + constructor Create(AOwner: TDMRDecoder); + end; + + TDMRDecoder = class + private + FEnabled: Boolean; + FRingLock: TCriticalSection; + FDSPLock: TCriticalSection; + FStatusLock: TCriticalSection; + FWake: PRTLEvent; + FThread: TDMRDecoderThread; + FMbe: Pointer; + FRing: array[0..DMR_RING_SIZE - 1] of Single; + FReadPos, FWritePos: Integer; + FDropped: QWord; + + FSampleWindow: array[0..DMR_SAMPLES_PER_SYM - 1] of Double; + FWindowPos, FWindowCount: Integer; + FWindowSum: Double; + FSampleNo: QWord; + FSyncShift: array[0..DMR_SAMPLES_PER_SYM - 1] of LongWord; + FRMSSq: Double; + + FSyncKind: TDMRSyncKind; + FInputInverted: Boolean; + FInverted: Boolean; + FSyncCount: QWord; + FLastSyncTick: QWord; + + function PopSamples(var Buf: array of Single): Integer; + procedure ProcessSample(S: Single); + procedure ProcessSymbol(Phase: Integer; Symbol: Double); + procedure NoteSync(Kind: TDMRSyncKind; Inverted: Boolean); + procedure ResetDSP; + public + constructor Create; + destructor Destroy; override; + + procedure SetEnabled(On_: Boolean); + procedure SetInverted(On_: Boolean); + procedure FeedAudio(const Left, Right: array of Single; Count: Integer); + procedure GetStatus(out S: TDMRStatus); + + class function SyncKindName(Kind: TDMRSyncKind): string; static; + property Enabled: Boolean read FEnabled; + end; + +implementation + +const + DMR_SYNC_MASK = LongWord($00FFFFFF); + DMR_SYNC_HOLD_MS = 500; + + // One bit per DSD-FME sync character: '1'=0, '3'=1. These are the eight + // ETSI DMR 48-bit sync patterns after collapsing each dibit to its sign. + SYNC_TEXT: array[0..7] of string = ( + '313333111331131131331131', // BS data + '131111333113313313113313', // BS voice + '311131133313133331131113', // MS data + '133313311131311113313331', // MS voice + '331333313111313133311111', // direct TS1 data + '113111131333131311133333', // direct TS1 voice + '311311111333113333133311', // direct TS2 data + '133133333111331111311133' // direct TS2 voice + ); + + SYNC_KIND: array[0..7] of TDMRSyncKind = ( + dskBSData, dskBSVoice, dskMSData, dskMSVoice, + dskDirectTS1Data, dskDirectTS1Voice, + dskDirectTS2Data, dskDirectTS2Voice + ); + +function SyncBits(const Text: string): LongWord; +var + i: Integer; +begin + Result := 0; + for i := 1 to Length(Text) do + begin + Result := (Result shl 1) and DMR_SYNC_MASK; + if Text[i] = '3' then Result := Result or 1; + end; +end; + +constructor TDMRDecoderThread.Create(AOwner: TDMRDecoder); +begin + inherited Create(True); + FreeOnTerminate := False; + FOwner := AOwner; +end; + +procedure TDMRDecoderThread.Execute; +var + Buf: array[0..2047] of Single; + N, i: Integer; +begin + while not Terminated do + begin + N := FOwner.PopSamples(Buf); + if N = 0 then + begin + RTLEventWaitFor(FOwner.FWake, 100); + Continue; + end; + FOwner.FDSPLock.Enter; + try + for i := 0 to N - 1 do + FOwner.ProcessSample(Buf[i]); + finally + FOwner.FDSPLock.Leave; + end; + end; +end; + +constructor TDMRDecoder.Create; +begin + inherited Create; + FRingLock := TCriticalSection.Create; + FDSPLock := TCriticalSection.Create; + FStatusLock := TCriticalSection.Create; + FWake := RTLEventCreate; + FThread := TDMRDecoderThread.Create(Self); + if DMRLoad then FMbe := DMRMbeCreate(3); + ResetDSP; + FThread.Start; +end; + +destructor TDMRDecoder.Destroy; +begin + if FThread <> nil then + begin + FThread.Terminate; + RTLEventSetEvent(FWake); + FThread.WaitFor; + FreeAndNil(FThread); + end; + DMRMbeDestroy(FMbe); + FMbe := nil; + DMRUnload; + RTLEventDestroy(FWake); + FStatusLock.Free; + FDSPLock.Free; + FRingLock.Free; + inherited Destroy; +end; + +procedure TDMRDecoder.ResetDSP; +begin + FDSPLock.Enter; + try + FRingLock.Enter; + try + FReadPos := 0; + FWritePos := 0; + FDropped := 0; + finally + FRingLock.Leave; + end; + FillChar(FSampleWindow, SizeOf(FSampleWindow), 0); + FillChar(FSyncShift, SizeOf(FSyncShift), 0); + FWindowPos := 0; + FWindowCount := 0; + FWindowSum := 0; + FSampleNo := 0; + FRMSSq := 0; + FStatusLock.Enter; + try + FSyncKind := dskNone; + FInverted := False; + FSyncCount := 0; + FLastSyncTick := 0; + finally + FStatusLock.Leave; + end; + finally + FDSPLock.Leave; + end; +end; + +procedure TDMRDecoder.SetEnabled(On_: Boolean); +begin + if FEnabled = On_ then Exit; + FEnabled := On_; + ResetDSP; + if On_ then RTLEventSetEvent(FWake); +end; + +procedure TDMRDecoder.SetInverted(On_: Boolean); +begin + if FInputInverted = On_ then Exit; + FInputInverted := On_; + if FEnabled then ResetDSP; +end; + +procedure TDMRDecoder.FeedAudio(const Left, Right: array of Single; + Count: Integer); +var + i, Next: Integer; + V: Single; +begin + if not FEnabled or (Count <= 0) then Exit; + Count := Min(Count, Min(Length(Left), Length(Right))); + FRingLock.Enter; + try + for i := 0 to Count - 1 do + begin + Next := (FWritePos + 1) and (DMR_RING_SIZE - 1); + if Next = FReadPos then + begin + Inc(FDropped); + Continue; + end; + V := 0.5 * (Left[i] + Right[i]); + FRing[FWritePos] := V; + FWritePos := Next; + end; + finally + FRingLock.Leave; + end; + RTLEventSetEvent(FWake); +end; + +function TDMRDecoder.PopSamples(var Buf: array of Single): Integer; +begin + Result := 0; + FRingLock.Enter; + try + while (FReadPos <> FWritePos) and (Result < Length(Buf)) do + begin + Buf[Result] := FRing[FReadPos]; + FReadPos := (FReadPos + 1) and (DMR_RING_SIZE - 1); + Inc(Result); + end; + finally + FRingLock.Leave; + end; +end; + +procedure TDMRDecoder.ProcessSample(S: Single); +var + Phase: Integer; + Sym: Double; +begin + FRMSSq := FRMSSq + 0.001 * (S * S - FRMSSq); + FWindowSum := FWindowSum - FSampleWindow[FWindowPos] + S; + FSampleWindow[FWindowPos] := S; + FWindowPos := (FWindowPos + 1) mod DMR_SAMPLES_PER_SYM; + if FWindowCount < DMR_SAMPLES_PER_SYM then Inc(FWindowCount); + Inc(FSampleNo); + if FWindowCount < DMR_SAMPLES_PER_SYM then Exit; + + // Ten interleaved timing hypotheses. Each receives one boxcar-integrated + // symbol every ten input samples; the correct phase produces exact sync. + Phase := Integer(FSampleNo mod DMR_SAMPLES_PER_SYM); + Sym := FWindowSum / DMR_SAMPLES_PER_SYM; + ProcessSymbol(Phase, Sym); +end; + +procedure TDMRDecoder.ProcessSymbol(Phase: Integer; Symbol: Double); +var + i: Integer; + Pat: LongWord; +begin + FSyncShift[Phase] := ((FSyncShift[Phase] shl 1) and DMR_SYNC_MASK); + if ((Symbol < 0) xor FInputInverted) then + FSyncShift[Phase] := FSyncShift[Phase] or 1; + + for i := 0 to High(SYNC_TEXT) do + begin + Pat := SyncBits(SYNC_TEXT[i]); + if FSyncShift[Phase] = Pat then + begin + NoteSync(SYNC_KIND[i], FInputInverted); + Exit; + end; + end; +end; + +procedure TDMRDecoder.NoteSync(Kind: TDMRSyncKind; Inverted: Boolean); +var + NowTick: QWord; +begin + NowTick := GetTickCount64; + FStatusLock.Enter; + try + // Adjacent timing hypotheses may recognize the same physical burst. + if (FLastSyncTick <> 0) and (NowTick - FLastSyncTick < 5) then Exit; + FSyncKind := Kind; + FInverted := Inverted; + Inc(FSyncCount); + FLastSyncTick := NowTick; + finally + FStatusLock.Leave; + end; +end; + +procedure TDMRDecoder.GetStatus(out S: TDMRStatus); +var + NowTick: QWord; +begin + FillChar(S, SizeOf(S), 0); + NowTick := GetTickCount64; + FDSPLock.Enter; + try + FStatusLock.Enter; + try + S.Enabled := FEnabled; + S.SyncKind := FSyncKind; + S.Inverted := FInverted; + S.SyncCount := FSyncCount; + if FLastSyncTick <> 0 then S.LastSyncAgeMs := NowTick - FLastSyncTick + else S.LastSyncAgeMs := High(QWord); + S.Synced := FEnabled and (FLastSyncTick <> 0) and + (S.LastSyncAgeMs <= DMR_SYNC_HOLD_MS); + S.SignalRMS := Sqrt(FRMSSq); + S.VocoderAvailable := FMbe <> nil; + finally + FStatusLock.Leave; + end; + finally + FDSPLock.Leave; + end; + FRingLock.Enter; + try + S.DroppedSamples := FDropped; + finally + FRingLock.Leave; + end; +end; + +class function TDMRDecoder.SyncKindName(Kind: TDMRSyncKind): string; +begin + case Kind of + dskBSData: Result := 'BS data'; + dskBSVoice: Result := 'BS voice'; + dskMSData: Result := 'MS data'; + dskMSVoice: Result := 'MS voice'; + dskDirectTS1Data: Result := 'direct TS1 data'; + dskDirectTS1Voice: Result := 'direct TS1 voice'; + dskDirectTS2Data: Result := 'direct TS2 data'; + dskDirectTS2Voice: Result := 'direct TS2 voice'; + else + Result := 'none'; + end; +end; + +end. diff --git a/MainForm.pas b/MainForm.pas index 428e99a..7b07eb2 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -40,7 +40,7 @@ uses WinFirewall, BoardUtils, WisdomBuilder, UISync, FlatEdit, FMRepeater, - ChannelStore, ChannelsForm, BeaconScopeForm, + ChannelStore, ChannelsForm, BeaconScopeForm, DMRDecoder, PowerInhibit, DeviceStore, RadioBackend, PlutoBackend, @@ -67,7 +67,7 @@ const WDSP_WISDOM_FILE = 'wdspWisdom00'; BAND_COUNT = 12; // макс. слотов среди планов (Pluto VHF/UHF = 12, HF = 11) - MODE_COUNT = 11; + MODE_COUNT = 12; // Полосы/дефолты фильтров (FILT_COUNT, FILT_*_BW, FILT_*_DEF, FILT_FM_*) // перенесены в Settings.pas — нужны TRadioController. Здесь остаются только // UI-подписи кнопок (NAMES). @@ -105,7 +105,7 @@ 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'); + 'LSB','USB','DSB','CWL','CWU','FM','AM','SAM','DIGU','DIGL','WFM','DMR'); MODE_ROWS = 3; // 4 колонки; последний ряд неполный SMETER_WIDTH_RATIO = 0.32; @@ -419,6 +419,7 @@ type procedure UpdateStatusField7; // поле 7: PLL (HPSDR) или beacon (Pluto) procedure UpdateStatusField3; // поле 3: Supply (HPSDR) или Temp/RSSI (Pluto) function BeaconStatusText: string; // компактный статус QO-100 beacon lock + function DMRStatusText: string; // sync/type/polarity DMR front-end procedure DoUpdateDDCSeq(DDCIdx: Integer; Seq: LongWord); // Event handlers @@ -3583,10 +3584,28 @@ begin end; end; +function TMainForm.DMRStatusText: string; +var + S: TDMRStatus; + Inv: string; +begin + if not FController.GetDMRStatus(S) then Exit('DMR unavailable'); + if not S.Enabled then Exit('DMR off'); + if not S.Synced then Exit(Format('DMR sync... %.3f', [S.SignalRMS])); + if S.Inverted then Inv := ' INV' else Inv := ''; + Result := Format('DMR %s%s #%d', + [TDMRDecoder.SyncKindName(S.SyncKind), Inv, S.SyncCount]); +end; + procedure TMainForm.UpdateStatusField7; // Поле 7 статус-бара. openHPSDR — PLL-lock (как прежде). Pluto в трансвертере — // статус QO-100 beacon lock (у Pluto нет PLL-телеметрии); Pluto без XVTR — пусто. begin + if FController.FMode = MODE_DMR then + begin + SetStatusText(7, DMRStatusText); + Exit; + end; if FController.IsPluto then begin if FController.FCurrentXvtr >= 0 then SetStatusText(7, BeaconStatusText) @@ -4203,6 +4222,24 @@ begin Exit; end; + if FController.FMode = MODE_DMR then + begin + CloseCTCSSPopup; + FController.FFilter := 0; + FController.FFilterBW := FILT_DMR_BW; + FController.FFMDeviation := DMR_FM_DEV; + BtnFilter[0].Caption := '12.5k'; + BtnFilter[0].Left := LeftPanelButtonLeft(PanelFilter.Width, 1, 0); + BtnFilter[0].Width := LeftPanelButtonWidth(PanelFilter.Width, 1, 0); + BtnFilter[0].Top := 16; + BtnFilter[0].Visible := True; + StyleButton(BtnFilter[0], True); + for i := 1 to FILT_COUNT - 1 do BtnFilter[i].Visible := False; + PanelFilter.Height := BtnFilter[0].Top + BtnFilter[0].Height + 4; + RelayoutBelowBands; + Exit; + end; + for i := 0 to FILT_COUNT - 1 do begin BtnFilter[i].Left := LeftPanelButtonLeft(PanelFilter.Width, 5, i mod 5); @@ -5323,6 +5360,12 @@ begin Break; end; end; + MODE_DMR: + begin + FController.FFilter := 0; + FController.FFilterBW := FILT_DMR_BW; + FController.FFMDeviation := DMR_FM_DEV; + end; end; if FController.FWDSPReady then begin diff --git a/RadioController.pas b/RadioController.pas index 6101ed2..853abaa 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -49,7 +49,7 @@ interface uses Classes, SysUtils, Math, HPSDRProtocol, HPSDRNetwork, RadioBackend, PlutoBackend, IIOBindings, - WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, BeaconFEC, + WDSPEngine, AudioOutput, AudioInput, BeaconDecoder, BeaconFEC, DMRDecoder, Settings, ChannelStore, FMRepeater, BoardUtils, DeviceStore; // Панадаптеры (этап 3): потолок MAX_PANS живёт в WDSPEngine (общий для @@ -249,6 +249,9 @@ type // (узкий фильтр, ставится кликом); 0 = ещё не наведён FBeaconDecInvert: Boolean; // зеркальный baseband (QO-100/LNB) — деф. True + // ---- DMR 4FSK receive decoder ---- + FDMRDec: TDMRDecoder; // 48 kHz FM discriminator -> DMR worker + // ---- Аудио / TX ---- FVolume: Integer; // громкость RX FTxMonVolume: Integer; // громкость self-monitor RX-аудио на TX (RX MUTE off, DUP) @@ -401,6 +404,7 @@ type // Чистая обработка данных без UI; навешиваются на FNetwork/FDSPEngine // вызывающим (GUI или демон) после CreateEngines. procedure OnAudioReady(const Left, Right: array of Single; Count: Integer); + procedure OnDemodAudioReady(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); procedure OnMicPacket(const Data: TMicDataPacket); @@ -670,6 +674,7 @@ type function BeaconDecodeInvert: Boolean; function GetBeaconScope(out S: TBeaconScope): Boolean; function GetBeaconFrame(out Frame: TBeaconFrame; out RSErrors: Integer): Boolean; + function GetDMRStatus(out S: TDMRStatus): Boolean; // Отображение / приём procedure SetCTun(On_: Boolean); @@ -827,6 +832,7 @@ begin // Роутинг аудио слайсов на их звуковухи — внутренний, вешаем сразу (не // зависит от фронтенда, в отличие от OnAudio/OnSpectrum). FDSPEngine.OnSliceAudio := OnSliceAudioReady; + FDSPEngine.OnDemodAudio := OnDemodAudioReady; FDSPEngine.SetDisplayResLimit(FDisplayPixels); // QO-100 beacon lock: эталон частоты = декодер (см. ServiceBeaconLock). @@ -843,6 +849,9 @@ begin FBeaconDec.SetInvert(FBeaconDecInvert); FDSPEngine.SetBeaconDecoder(FBeaconDec); + // DMR front-end owns a worker thread. It stays idle until MODE_DMR is active. + FDMRDec := TDMRDecoder.Create; + // Audio out/in — объекты создаём сейчас, Open вызывается позже (после показа // формы / при подключении устройства). FAudioOut := TAudioOutput.Create(48000); @@ -874,6 +883,7 @@ begin FDSPEngine.Close; FreeAndNil(FDSPEngine); end; + if Assigned(FDMRDec) then FreeAndNil(FDMRDec); if Assigned(FBeaconDec) then FreeAndNil(FBeaconDec); end; @@ -1148,6 +1158,10 @@ procedure TRadioController.OnAudioReady(const Left, Right: array of Single; // port 1028) — независимый канал. Затем web-адаптер может перехватить аудио // (активный клиент); если перехвачено — в локальный AudioOut не пишем. 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 FSendAudioToRadio then FNetwork.SendSpeakerAudio(Left, Right, Count); if Assigned(FOnAudioConsume) and FOnAudioConsume(Left, Right, Count) then @@ -1158,6 +1172,20 @@ begin FAudioOut.Write(Left, Right, Count); end; +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 FDMRDec.Enabled then FDMRDec.SetEnabled(True); + FDMRDec.FeedAudio(Left, Right, Count); + end + else if FDMRDec.Enabled then + FDMRDec.SetEnabled(False); +end; + // --------------------------------------------------------------------------- // Мультислайсы (софт-fan-out) // --------------------------------------------------------------------------- @@ -2157,6 +2185,13 @@ begin Result := Assigned(FBeaconDec) and FBeaconDec.GetLastFrame(Frame, RSErrors); end; +function TRadioController.GetDMRStatus(out S: TDMRStatus): Boolean; +begin + Result := Assigned(FDMRDec); + if Result then FDMRDec.GetStatus(S) + else FillChar(S, SizeOf(S), 0); +end; + // Кромки полосы под режим/полосу (единый расчёт для главного канала и слайсов). class procedure TRadioController.FilterEdgesFor(Mode, BW: Integer; out Lo, Hi: Integer); var Half: Integer; @@ -2190,12 +2225,13 @@ begin begin FDSPEngine.SetFilter(Lo, Hi); FDSPEngine.SetTXFilter(Lo, Hi); - if FMode = MODE_FM then + if (FMode = MODE_FM) or (FMode = MODE_DMR) then begin FDSPEngine.SetRXFMDeviation(FFMDeviation); FDSPEngine.SetTXFMParams(FFMDeviation, FTXSettings.FMLowCut, FTXSettings.FMHighCut, FTXSettings.FMEmphPosition); - FDSPEngine.SetFMSquelch(FFMSQOn, FFMSQLevel); + // A squelch would remove sync preambles; DMR always receives open. + FDSPEngine.SetFMSquelch((FMode = MODE_FM) and FFMSQOn, FFMSQLevel); end else FDSPEngine.SetFMSquelch(False, 0); @@ -2219,6 +2255,12 @@ begin if FFilter > FILT_WFM_COUNT - 1 then FFilter := FILT_WFM_DEF; FFilterBW := FILT_WFM_BW[FFilter]; end; + MODE_DMR: + begin + FFilter := 0; + FFilterBW := FILT_DMR_BW; + FFMDeviation := DMR_FM_DEV; + end; MODE_LSB, MODE_USB: begin FFilter := FILT_SSB_DEF; FFilterBW := FILT_SSB_BW[FFilter]; end; MODE_DIGU, MODE_DIGL: @@ -2238,6 +2280,7 @@ begin case M of 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_LSB, MODE_USB: Result := FILT_SSB_BW[EnsureRange(Idx, 0, FILT_COUNT - 1)]; MODE_DIGU, MODE_DIGL: @@ -2310,6 +2353,12 @@ begin FFilterBW := FILT_FM_BW[FFilter]; FFMDeviation := FILT_FM_DEV[FFilter]; end; + if FMode = MODE_DMR then + begin + FFilter := 0; + FFilterBW := FILT_DMR_BW; + FFMDeviation := DMR_FM_DEV; + end; end; function TRadioController.MakeBandSettings: TBandSettings; @@ -2480,6 +2529,7 @@ procedure TRadioController.ApplyBandDSP(const B: TBandSettings); begin // --- Режим --- FMode := B.Mode; + if Assigned(FDMRDec) then FDMRDec.SetEnabled(FMode = MODE_DMR); if FWDSPReady and Assigned(FDSPEngine) then FDSPEngine.SetMode(FMode); // --- Фильтр (санитизация FM до применения к DSP — как в оригинале) --- @@ -2870,6 +2920,7 @@ end; procedure TRadioController.SetMode(M: Integer); begin FMode := M; + if Assigned(FDMRDec) then FDMRDec.SetEnabled(FMode = MODE_DMR); ApplyModeDefaults; // дефолтный фильтр/девиация под режим FBandCache[FCurrentBand].Mode := FMode; if FWDSPReady and Assigned(FDSPEngine) then @@ -2889,6 +2940,7 @@ begin case FMode of MODE_FM: Idx := EnsureRange(Idx, 0, FILT_FM_COUNT - 1); MODE_WFM: Idx := EnsureRange(Idx, 0, FILT_WFM_COUNT - 1); + MODE_DMR: Idx := 0; else Idx := EnsureRange(Idx, 0, FILT_COUNT - 1); end; FFilter := Idx; @@ -2898,6 +2950,7 @@ begin MODE_DSB: FFilterBW := FILT_DSB_BW[Idx]; MODE_CWL, MODE_CWU: FFilterBW := FILT_CW_BW[Idx]; MODE_WFM: FFilterBW := FILT_WFM_BW[Idx]; + MODE_DMR: FFilterBW := FILT_DMR_BW; MODE_FM: begin FFilterBW := FILT_FM_BW[Idx]; @@ -2929,6 +2982,7 @@ begin for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_CW_BW[i]; 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; else for i := 0 to FILT_COUNT-1 do BW_arr[i] := FILT_AM_BW[i]; end; best := 0; dist := MaxInt; diff --git a/Settings.pas b/Settings.pas index 0b5cb7d..0163808 100644 --- a/Settings.pas +++ b/Settings.pas @@ -60,6 +60,10 @@ const FILT_WFM_BW: array[0..2] of Integer = (200000, 160000, 120000); FILT_WFM_DEF = 1; // 160k — влезает и в 192k + // DMR Tier II: 12.5 kHz RF channel, 4FSK nominal deviation +/-1.944 kHz. + FILT_DMR_BW = 12500; + DMR_FM_DEV = 2500; + type TBandSettings = record VfoA: Double; diff --git a/VfoOverlay.pas b/VfoOverlay.pas index 19d0bfe..151be77 100644 --- a/VfoOverlay.pas +++ b/VfoOverlay.pas @@ -24,11 +24,11 @@ const OVL_W = 280; // ширина карточки // Индексы совпадают с MODE_* контроллера (WDSPEngine.pas). - OVL_MODE_COUNT = 11; + OVL_MODE_COUNT = 12; 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'); + 'LSB','USB','DSB','CWL','CWU','FM','AM','SAM','DIGU','DIGL','WFM','DMR'); // Палитра слайсов (TColor = $00BBGGRR): одна на букву — общий цвет для // бейджа-карточки во флаге и для буквы на полосе фильтра в спектре. @@ -394,6 +394,7 @@ begin 5: Result := FM_BW[FilterIdx]; 8,9: Result := DIGI_BW[FilterIdx]; // DIGU/DIGL — полоса от нуля 10: Result := WFM_BW[FilterIdx]; + 11: Result := 12500; else Result := AM_BW[FilterIdx]; end; end; @@ -407,6 +408,7 @@ begin 5: Result := FM_LBL[FilterIdx]; 8,9: Result := DIGI_LBL[FilterIdx]; 10: Result := WFM_LBL[FilterIdx]; + 11: Result := '12.5k'; else Result := AM_LBL[FilterIdx]; end; end; @@ -416,6 +418,7 @@ begin case ModeIdx of 5: Result := 2; // FM: NFM/FM 10: Result := 3; // WFM: 200K/160K/120K + 11: Result := 1; // DMR: fixed 12.5 kHz channel else Result := 6; end; end; diff --git a/WDSPEngine.pas b/WDSPEngine.pas index 39d2276..61c6313 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -60,6 +60,7 @@ const MODE_DIGU = 8; MODE_DIGL = 9; MODE_WFM = 10; + MODE_DMR = 11; // DMR receive: WDSP NFM discriminator -> DMRDecoder // Минимальная длина FIR у WDSP: create_* поднимают fircore с // nc = max(2048, dsp_size). В firmin.c nfor = nc / size, idxmask = nfor - 1, @@ -322,6 +323,7 @@ type FViewHighHz: Double; FOnAudio: TOnAudioReady; + FOnDemodAudio:TOnAudioReady; // pre-volume/mute tap for digital decoders FOnSpectrum: TOnSpectrumReady; FOnWaterfall: TOnWaterfallReady; FOnTXIQ: TOnTXIQReady; @@ -682,6 +684,7 @@ type property WfAvgMode: Integer read FWfAvgMode; property WfAvgTimeMS: Double read FWfAvgTimeMS; property OnAudio: TOnAudioReady read FOnAudio write FOnAudio; + property OnDemodAudio: TOnAudioReady read FOnDemodAudio write FOnDemodAudio; property OnSliceAudio: TOnSliceAudio read FOnSliceAudio write FOnSliceAudio; property OnSpectrum: TOnSpectrumReady read FOnSpectrum write FOnSpectrum; property OnWaterfall: TOnWaterfallReady read FOnWaterfall write FOnWaterfall; @@ -930,6 +933,7 @@ begin MODE_DIGU: Result := WDSP_DIGU; MODE_DIGL: Result := WDSP_DIGL; MODE_WFM: Result := WDSP_RX_WFM; + MODE_DMR: Result := WDSP_FM; else Result := WDSP_USB; end; end; @@ -1082,6 +1086,7 @@ begin MODE_DIGU: SetFilter( 0, 3000); MODE_DIGL: SetFilter(-3000, 0); MODE_WFM: SetFilter(-80000, 80000); + MODE_DMR: SetFilter(-6250, 6250); end; end; @@ -2087,16 +2092,29 @@ begin // Full-duplex self-monitor (QO-100): при FMonitorRXDuringTX не глушим RX-аудио // на TX — слышим свой downlink со спутника. Иначе (по умолчанию) глушим, как // Thetis/piHPSDR при не-duplex MOX. FPostTXMuteUntil-хвост уважаем в любом случае. - if Assigned(FOnAudio) and not FMuted - and (not FTXActive or (FKeepRXDuringTX and FMonitorRXDuringTX)) + if (not FTXActive or (FKeepRXDuringTX and FMonitorRXDuringTX)) and (FPostTXMuteUntil = 0) then begin - for i := 0 to FAudioBufSize - 1 do + // Decoder tap is independent from speaker mute and volume. This is the + // actual demodulator output; digital sync must survive MUTE/volume=0. + if Assigned(FOnDemodAudio) then begin - FOutL[i] := FRXOut[i * 2] * FVolume; - FOutR[i] := FRXOut[i * 2 + 1] * FVolume; + for i := 0 to FAudioBufSize - 1 do + begin + FOutL[i] := FRXOut[i * 2]; + FOutR[i] := FRXOut[i * 2 + 1]; + end; + FOnDemodAudio(FOutL, FOutR, FAudioBufSize); + end; + if Assigned(FOnAudio) and not FMuted then + begin + for i := 0 to FAudioBufSize - 1 do + begin + FOutL[i] := FRXOut[i * 2] * FVolume; + FOutR[i] := FRXOut[i * 2 + 1] * FVolume; + end; + FOnAudio(FOutL, FOutR, FAudioBufSize); end; - FOnAudio(FOutL, FOutR, FAudioBufSize); end; // Софтовые слайсы: fan-out того же IQ-блока на доп. WDSP-каналы. diff --git a/WebAdapter.pas b/WebAdapter.pas index d2da3f9..c827ca2 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_WFM) then Exit; + if (FSyncInt < 0) or (FSyncInt > MODE_DMR) then Exit; FController.SetMode(FSyncInt); // дефолт фильтра + DSP + cache; рендер через rfMode end; diff --git a/WebPageHtml.pas b/WebPageHtml.pas index 8e71b6b..74ad1ba 100644 --- a/WebPageHtml.pas +++ b/WebPageHtml.pas @@ -193,7 +193,7 @@ begin '' + '