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
'' +
'