feat(dmr): decode and route AMBE voice

This commit is contained in:
2026-07-14 18:55:28 +03:00
parent e0e74b5337
commit 28c434d9a7
7 changed files with 231 additions and 10 deletions
+1 -1
View File
@@ -13,4 +13,4 @@ cmake --build build/dmr
fpc -Fu. -FE/tmp tests/dmr_protocol_test.pas fpc -Fu. -FE/tmp tests/dmr_protocol_test.pas
/tmp/dmr_protocol_test /tmp/dmr_protocol_test
fpc -Fu. -FE/tmp tests/dmr_frontend_test.pas fpc -Fu. -FE/tmp tests/dmr_frontend_test.pas
/tmp/dmr_frontend_test LD_LIBRARY_PATH=build/dmr /tmp/dmr_frontend_test
+58
View File
@@ -27,8 +27,11 @@ const
DMR_RING_SIZE = 65536; // >1.3 s at 48 kHz, power of two DMR_RING_SIZE = 65536; // >1.3 s at 48 kHz, power of two
DMR_BURST_DIBITS = 144; DMR_BURST_DIBITS = 144;
DMR_SYNC_END_DIBIT = 89; // sync occupies burst dibits 66..89 DMR_SYNC_END_DIBIT = 89; // sync occupies burst dibits 66..89
DMR_VOICE_FRAME_SAMPLES = DMR_PCM_SAMPLES; // 20 ms at 8 kHz
type type
TDMRAudioEvent = procedure(const PCM: array of Single; Count: Integer) of object;
TDMRSyncKind = ( TDMRSyncKind = (
dskNone, dskNone,
dskBSData, dskBSVoice, dskBSData, dskBSVoice,
@@ -62,6 +65,9 @@ type
ServiceOptions: Integer; ServiceOptions: Integer;
TargetID: LongWord; TargetID: LongWord;
SourceID: LongWord; SourceID: LongWord;
SelectedSlot: Integer;
VoiceFrameCount: QWord;
VoiceErrorCount: QWord;
end; end;
TDMRDecoder = class; TDMRDecoder = class;
@@ -111,6 +117,9 @@ type
FLastLCSlot: Integer; FLastLCSlot: Integer;
FColorCodeValid: Boolean; FColorCodeValid: Boolean;
FColorCode, FLastDataType, FSlotTypeCorrectedBits: Integer; FColorCode, FLastDataType, FSlotTypeCorrectedBits: Integer;
FSelectedSlot: Integer;
FVoiceFrameCount, FVoiceErrorCount: QWord;
FOnAudio: TDMRAudioEvent;
FBurstCount: QWord; FBurstCount: QWord;
FRMSSq: Double; FRMSSq: Double;
@@ -127,6 +136,7 @@ type
function Digitize(Phase: Integer; Symbol: Double): Byte; function Digitize(Phase: Integer; Symbol: Double): Byte;
procedure StartBurstTracking(Phase: Integer); procedure StartBurstTracking(Phase: Integer);
procedure CompleteBurst; procedure CompleteBurst;
procedure DecodeVoiceBurst;
procedure ResetDSP; procedure ResetDSP;
public public
constructor Create; constructor Create;
@@ -140,6 +150,7 @@ type
class function SyncKindName(Kind: TDMRSyncKind): string; static; class function SyncKindName(Kind: TDMRSyncKind): string; static;
property Enabled: Boolean read FEnabled; property Enabled: Boolean read FEnabled;
property OnAudio: TDMRAudioEvent read FOnAudio write FOnAudio;
end; end;
implementation implementation
@@ -275,6 +286,10 @@ begin
FColorCode := 0; FColorCode := 0;
FLastDataType := 0; FLastDataType := 0;
FSlotTypeCorrectedBits := 0; FSlotTypeCorrectedBits := 0;
FSelectedSlot := -1;
FVoiceFrameCount := 0;
FVoiceErrorCount := 0;
DMRMbeReset(FMbe);
FTrackPhase := -1; FTrackPhase := -1;
FCurrentBurstPos := 0; FCurrentBurstPos := 0;
FBurstCount := 0; FBurstCount := 0;
@@ -480,11 +495,51 @@ begin
FLastLCSlot := 1 FLastLCSlot := 1
else else
FLastLCSlot := 0; FLastLCSlot := 0;
if FSelectedSlot < 0 then FSelectedSlot := FLastLCSlot;
end; end;
DecodeVoiceBurst;
Inc(FBurstCount); Inc(FBurstCount);
FCurrentBurstPos := 0; FCurrentBurstPos := 0;
end; end;
procedure TDMRDecoder.DecodeVoiceBurst;
var
Frames: TDMRAMBEFrames;
PCM: array[0..DMR_PCM_SAMPLES - 1] of Single;
MbeResult: TDMRMbeResult;
Slot, FrameIndex, RC: Integer;
begin
if (FMbe = nil) or (FLastSyncTick = 0) or
(GetTickCount64 - FLastSyncTick > DMR_SYNC_HOLD_MS) then Exit;
if not (FSyncKind in [dskBSVoice, dskMSVoice, dskDirectTS1Voice,
dskDirectTS2Voice]) then Exit;
if FSyncKind in [dskBSVoice] then
begin
if not FLastBurstInfo.CACH.Valid then Exit;
Slot := FLastBurstInfo.CACH.Slot;
end
else if FSyncKind = dskDirectTS2Voice then Slot := 1
else Slot := 0;
if FSelectedSlot < 0 then FSelectedSlot := Slot;
if Slot <> FSelectedSlot then Exit;
DMRExtractAMBEFrames(FLastBurst, Frames);
for FrameIndex := 0 to 2 do
begin
FillChar(MbeResult, SizeOf(MbeResult), 0);
RC := DMRMbeDecode(FMbe, @Frames[FrameIndex][0], @PCM[0], @MbeResult);
if RC <> 0 then
begin
Inc(FVoiceErrorCount);
Continue;
end;
Inc(FVoiceFrameCount);
if MbeResult.ErrorsTotal > 0 then Inc(FVoiceErrorCount, MbeResult.ErrorsTotal);
if Assigned(FOnAudio) then FOnAudio(PCM, Length(PCM));
end;
end;
procedure TDMRDecoder.NoteSync(Phase: Integer; Kind: TDMRSyncKind; procedure TDMRDecoder.NoteSync(Phase: Integer; Kind: TDMRSyncKind;
Inverted: Boolean); Inverted: Boolean);
var var
@@ -559,6 +614,9 @@ begin
S.ServiceOptions := FLastLinkControl.ServiceOptions; S.ServiceOptions := FLastLinkControl.ServiceOptions;
S.TargetID := FLastLinkControl.TargetID; S.TargetID := FLastLinkControl.TargetID;
S.SourceID := FLastLinkControl.SourceID; S.SourceID := FLastLinkControl.SourceID;
S.SelectedSlot := FSelectedSlot;
S.VoiceFrameCount := FVoiceFrameCount;
S.VoiceErrorCount := FVoiceErrorCount;
finally finally
FStatusLock.Leave; FStatusLock.Leave;
end; end;
+77 -3
View File
@@ -3,9 +3,21 @@ unit DMRProtocol;
{ {
Small, allocation-free DMR burst protocol layer. Small, allocation-free DMR burst protocol layer.
The CACH interleave and Hamming/Golay generator matrices are derived from The CACH/AMBE interleavers and Hamming/Golay matrices are derived from
dsd-fme (src/dmr_bs.c and src/fec.c). The original code is distributed dsd-fme (src/dmr_bs.c, src/fec.c and include/dmr_const.h).
under the ISC licence; see the dsd-fme source tree for its full notice.
Copyright (C) 2010 DSD Author
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
} }
{$IFDEF FPC} {$IFDEF FPC}
@@ -25,6 +37,8 @@ type
TDMRBit7 = array[0..6] of Byte; TDMRBit7 = array[0..6] of Byte;
TDMRBit20 = array[0..19] of Byte; TDMRBit20 = array[0..19] of Byte;
TDMRBit96 = array[0..95] of Byte; TDMRBit96 = array[0..95] of Byte;
TDMRAMBEFrame = array[0..95] of Byte;
TDMRAMBEFrames = array[0..2] of TDMRAMBEFrame;
TDMRCACH = record TDMRCACH = record
Valid: Boolean; Valid: Boolean;
@@ -68,6 +82,10 @@ function DMRGolay208Encode(Value: Byte): TDMRBit20;
procedure DMRBPTC19696Encode(const Payload: TDMRBit96; procedure DMRBPTC19696Encode(const Payload: TDMRBit96;
out Burst: TDMRDibitBurst); out Burst: TDMRDibitBurst);
procedure DMRFullLCSetParity(var Payload: TDMRBit96; DataType: Integer); procedure DMRFullLCSetParity(var Payload: TDMRBit96; DataType: Integer);
procedure DMRExtractAMBEFrames(const Burst: TDMRDibitBurst;
out Frames: TDMRAMBEFrames);
procedure DMRInsertAMBEFrames(const Frames: TDMRAMBEFrames;
var Burst: TDMRDibitBurst);
function DMRDecodeCACH(const Burst: TDMRDibitBurst; out CACH: TDMRCACH): Boolean; function DMRDecodeCACH(const Burst: TDMRDibitBurst; out CACH: TDMRCACH): Boolean;
function DMRDecodeSlotType(const Burst: TDMRDibitBurst; function DMRDecodeSlotType(const Burst: TDMRDibitBurst;
out SlotType: TDMRSlotType): Boolean; out SlotType: TDMRSlotType): Boolean;
@@ -110,6 +128,19 @@ const
HAMMING_15_11_SYNDROME: array[0..14] of Byte = HAMMING_15_11_SYNDROME: array[0..14] of Byte =
(9,13,15,14,7,10,5,11,12,6,3, 8,4,2,1); (9,13,15,14,7,10,5,11,12,6,3, 8,4,2,1);
AMBE_W: array[0..35] of Byte = (
0,1,0,1,0,1, 0,1,0,1,0,1, 0,1,0,1,0,1,
0,1,0,1,0,2, 0,2,0,2,0,2, 0,2,0,2,0,2);
AMBE_X: array[0..35] of Byte = (
23,10,22,9,21,8, 20,7,19,6,18,5, 17,4,16,3,15,2,
14,1,13,0,12,10, 11,9,10,8,9,7, 8,6,7,5,6,4);
AMBE_Y: array[0..35] of Byte = (
0,2,0,2,0,2, 0,2,0,3,0,3, 1,3,1,3,1,3,
1,3,1,3,1,3, 1,3,1,3,1,3, 1,3,1,3,1,3);
AMBE_Z: array[0..35] of Byte = (
5,3,4,2,3,1, 2,0,1,13,0,12, 22,11,21,10,20,9,
19,8,18,7,17,6, 16,5,15,4,14,3, 13,2,12,1,11,0);
function DMRHamming74Encode(Value: Byte): TDMRBit7; function DMRHamming74Encode(Value: Byte): TDMRBit7;
var var
i, j: Integer; i, j: Integer;
@@ -203,6 +234,49 @@ begin
end; end;
end; end;
function AMBEDibitIndex(FrameIndex, DibitIndex: Integer): Integer;
begin
case FrameIndex of
0: Result := 12 + DibitIndex;
1: if DibitIndex < 18 then Result := 48 + DibitIndex
else Result := 90 + DibitIndex - 18;
else
Result := 108 + DibitIndex;
end;
end;
procedure DMRExtractAMBEFrames(const Burst: TDMRDibitBurst;
out Frames: TDMRAMBEFrames);
var
FrameIndex, i, DibitIndex: Integer;
Dibit: Byte;
begin
FillChar(Frames, SizeOf(Frames), 0);
for FrameIndex := 0 to 2 do
for i := 0 to 35 do
begin
DibitIndex := AMBEDibitIndex(FrameIndex, i);
Dibit := Burst[DibitIndex];
Frames[FrameIndex][AMBE_W[i] * 24 + AMBE_X[i]] := (Dibit shr 1) and 1;
Frames[FrameIndex][AMBE_Y[i] * 24 + AMBE_Z[i]] := Dibit and 1;
end;
end;
procedure DMRInsertAMBEFrames(const Frames: TDMRAMBEFrames;
var Burst: TDMRDibitBurst);
var
FrameIndex, i, DibitIndex: Integer;
begin
for FrameIndex := 0 to 2 do
for i := 0 to 35 do
begin
DibitIndex := AMBEDibitIndex(FrameIndex, i);
Burst[DibitIndex] :=
((Frames[FrameIndex][AMBE_W[i] * 24 + AMBE_X[i]] and 1) shl 1) or
(Frames[FrameIndex][AMBE_Y[i] * 24 + AMBE_Z[i]] and 1);
end;
end;
function DecodeHamming74(const Received: TDMRBit7; out Value: Byte; function DecodeHamming74(const Received: TDMRBit7; out Value: Byte;
out Corrected: Integer): Boolean; out Corrected: Integer): Boolean;
var var
+42 -1
View File
@@ -405,6 +405,7 @@ type
// вызывающим (GUI или демон) после CreateEngines. // вызывающим (GUI или демон) после CreateEngines.
procedure OnAudioReady(const Left, Right: array of Single; Count: Integer); procedure OnAudioReady(const Left, Right: array of Single; Count: Integer);
procedure OnDemodAudioReady(const Left, Right: array of Single; Count: Integer); procedure OnDemodAudioReady(const Left, Right: array of Single; Count: Integer);
procedure OnDMRAudioReady(const PCM: array of Single; Count: Integer);
procedure OnSpectrumReady(const Pixels: array of Single; Count: Integer); procedure OnSpectrumReady(const Pixels: array of Single; Count: Integer);
procedure OnWaterfallReady(const Pixels: array of Single; Count: Integer); procedure OnWaterfallReady(const Pixels: array of Single; Count: Integer);
procedure OnMicPacket(const Data: TMicDataPacket); procedure OnMicPacket(const Data: TMicDataPacket);
@@ -851,6 +852,7 @@ begin
// DMR front-end owns a worker thread. It stays idle until MODE_DMR is active. // DMR front-end owns a worker thread. It stays idle until MODE_DMR is active.
FDMRDec := TDMRDecoder.Create; FDMRDec := TDMRDecoder.Create;
FDMRDec.OnAudio := OnDMRAudioReady;
// Audio out/in — объекты создаём сейчас, Open вызывается позже (после показа // Audio out/in — объекты создаём сейчас, Open вызывается позже (после показа
// формы / при подключении устройства). // формы / при подключении устройства).
@@ -862,6 +864,13 @@ end;
procedure TRadioController.FreeEngines; procedure TRadioController.FreeEngines;
var i: Integer; var i: Integer;
begin begin
// Stop the DMR worker before destroying any of its audio destinations.
if Assigned(FDMRDec) then
begin
FDMRDec.SetEnabled(False);
FDMRDec.OnAudio := nil;
FreeAndNil(FDMRDec);
end;
if Assigned(FNetwork) then if Assigned(FNetwork) then
begin begin
if FNetwork.Connected then FNetwork.Disconnect; if FNetwork.Connected then FNetwork.Disconnect;
@@ -883,7 +892,6 @@ begin
FDSPEngine.Close; FDSPEngine.Close;
FreeAndNil(FDSPEngine); FreeAndNil(FDSPEngine);
end; end;
if Assigned(FDMRDec) then FreeAndNil(FDMRDec);
if Assigned(FBeaconDec) then FreeAndNil(FBeaconDec); if Assigned(FBeaconDec) then FreeAndNil(FBeaconDec);
end; end;
@@ -1186,6 +1194,39 @@ begin
FDMRDec.SetEnabled(False); FDMRDec.SetEnabled(False);
end; end;
procedure TRadioController.OnDMRAudioReady(const PCM: array of Single;
Count: Integer);
// DMR worker thread, 8 kHz mono from mbelib. Linear 6x interpolation brings
// it to the common 48 kHz output route used by local, web and radio speakers.
var
Left, Right: array of Single;
i, Phase, OutPos, N: Integer;
A, B, V, Gain: Single;
begin
if (FMode <> MODE_DMR) or FMuted or (Count <= 0) then Exit;
N := Min(Count, Length(PCM));
SetLength(Left, N * 6);
SetLength(Right, N * 6);
Gain := FVolume / 100.0;
OutPos := 0;
for i := 0 to N - 1 do
begin
A := PCM[i];
if i + 1 < N then B := PCM[i + 1] else B := A;
for Phase := 0 to 5 do
begin
V := (A + (B - A) * (Phase / 6.0)) * Gain;
Left[OutPos] := V;
Right[OutPos] := V;
Inc(OutPos);
end;
end;
if FSendAudioToRadio and Assigned(FNetwork) then
FNetwork.SendSpeakerAudio(Left, Right, OutPos);
if Assigned(FOnAudioConsume) and FOnAudioConsume(Left, Right, OutPos) then Exit;
if FLocalAudio and Assigned(FAudioOut) then FAudioOut.Write(Left, Right, OutPos);
end;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Мультислайсы (софт-fan-out) // Мультислайсы (софт-fan-out)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+8 -4
View File
@@ -11,7 +11,11 @@ cmake -S native/dmr -B build/dmr -DCMAKE_BUILD_TYPE=Release
cmake --build build/dmr cmake --build build/dmr
``` ```
The current ABI implements the stateful AMBE+2 vocoder boundary. The burst/FEC The Pascal burst/FEC layer extracts three row-major `char[4][24]` AMBE+2
layer will feed its row-major `char[4][24]` output to this library. Keep the ABI frames from each selected voice-slot burst and feeds them through this ABI.
versioned: the GUI and daemon must remain usable when the optional library is Decoded 8 kHz mono PCM is resampled to EWSDR's 48 kHz speaker/web route.
not installed.
For a build-tree run, either copy `libewsdr_dmr.so` beside the EWSDR executable
or add `build/dmr` to `LD_LIBRARY_PATH`. `cmake --install build/dmr` installs the
adapter system-wide. The GUI and daemon remain usable (with metadata but no
voice) when this optional library is absent.
+27
View File
@@ -9,6 +9,20 @@ uses
const const
BS_VOICE_SYNC = '131111333113313313113313'; BS_VOICE_SYNC = '131111333113313313113313';
type
TAudioSink = class
public
CallCount: Integer;
SampleCount: Integer;
procedure AudioReady(const PCM: array of Single; Count: Integer);
end;
procedure TAudioSink.AudioReady(const PCM: array of Single; Count: Integer);
begin
Inc(CallCount);
Inc(SampleCount, Count);
end;
procedure FeedSync(D: TDMRDecoder; const Pattern: string; Invert: Boolean); procedure FeedSync(D: TDMRDecoder; const Pattern: string; Invert: Boolean);
var var
L, R: array of Single; L, R: array of Single;
@@ -95,9 +109,12 @@ end;
var var
D: TDMRDecoder; D: TDMRDecoder;
S: TDMRStatus; S: TDMRStatus;
Sink: TAudioSink;
begin begin
Sink := TAudioSink.Create;
D := TDMRDecoder.Create; D := TDMRDecoder.Create;
try try
D.OnAudio := Sink.AudioReady;
D.SetEnabled(True); D.SetEnabled(True);
FeedSync(D, BS_VOICE_SYNC, False); FeedSync(D, BS_VOICE_SYNC, False);
Sleep(50); Sleep(50);
@@ -124,8 +141,18 @@ begin
D.SetInverted(False); D.SetInverted(False);
D.SetEnabled(True); D.SetEnabled(True);
TestBurst(D); TestBurst(D);
D.GetStatus(S);
if S.VocoderAvailable and
((Sink.CallCount <> 3) or
(Sink.SampleCount <> 3 * DMR_VOICE_FRAME_SAMPLES)) then
begin
WriteLn('AMBE callback failed: calls=', Sink.CallCount,
' samples=', Sink.SampleCount);
Halt(5);
end;
WriteLn('DMR front-end sync tests passed'); WriteLn('DMR front-end sync tests passed');
finally finally
D.Free; D.Free;
Sink.Free;
end; end;
end. end.
+18 -1
View File
@@ -76,6 +76,8 @@ var
Burst: TDMRDibitBurst; Burst: TDMRDibitBurst;
Info: TDMRBurstInfo; Info: TDMRBurstInfo;
Payload: TDMRBit96; Payload: TDMRBit96;
Frames: TDMRAMBEFrames;
VoiceDibits: array[0..107] of Byte;
i: Integer; i: Integer;
begin begin
FillChar(Burst, SizeOf(Burst), 0); FillChar(Burst, SizeOf(Burst), 0);
@@ -155,5 +157,20 @@ begin
DMRParseBurst(Burst, Info); DMRParseBurst(Burst, Info);
Check(not Info.LinkControl.Decoded, 'invalid Full LC RS checksum accepted'); Check(not Info.LinkControl.Decoded, 'invalid Full LC RS checksum accepted');
WriteLn('DMR CACH, Slot Type, BPTC and Full LC tests passed'); // Three AMBE frames occupy 36 + 18/18 + 36 dibits around the centre sync.
FillChar(Burst, SizeOf(Burst), 0);
for i := 12 to 65 do Burst[i] := (i * 3 + 1) and 3;
for i := 90 to 143 do Burst[i] := (i * 3 + 2) and 3;
for i := 0 to 53 do VoiceDibits[i] := Burst[12 + i];
for i := 0 to 53 do VoiceDibits[54 + i] := Burst[90 + i];
DMRExtractAMBEFrames(Burst, Frames);
for i := 12 to 65 do Burst[i] := 0;
for i := 90 to 143 do Burst[i] := 0;
DMRInsertAMBEFrames(Frames, Burst);
for i := 0 to 53 do Check(Burst[12 + i] = VoiceDibits[i],
'AMBE frame 1/2 interleave mismatch');
for i := 0 to 53 do Check(Burst[90 + i] = VoiceDibits[54 + i],
'AMBE frame 2/3 interleave mismatch');
WriteLn('DMR protocol, FEC, Full LC and AMBE extraction tests passed');
end. end.