diff --git a/BUILD.MD b/BUILD.MD index 34d311d..ace3302 100644 --- a/BUILD.MD +++ b/BUILD.MD @@ -13,4 +13,4 @@ cmake --build build/dmr fpc -Fu. -FE/tmp tests/dmr_protocol_test.pas /tmp/dmr_protocol_test fpc -Fu. -FE/tmp tests/dmr_frontend_test.pas -/tmp/dmr_frontend_test +LD_LIBRARY_PATH=build/dmr /tmp/dmr_frontend_test diff --git a/DMRDecoder.pas b/DMRDecoder.pas index 69d7cfc..b1d9eb3 100644 --- a/DMRDecoder.pas +++ b/DMRDecoder.pas @@ -27,8 +27,11 @@ const DMR_RING_SIZE = 65536; // >1.3 s at 48 kHz, power of two DMR_BURST_DIBITS = 144; DMR_SYNC_END_DIBIT = 89; // sync occupies burst dibits 66..89 + DMR_VOICE_FRAME_SAMPLES = DMR_PCM_SAMPLES; // 20 ms at 8 kHz type + TDMRAudioEvent = procedure(const PCM: array of Single; Count: Integer) of object; + TDMRSyncKind = ( dskNone, dskBSData, dskBSVoice, @@ -62,6 +65,9 @@ type ServiceOptions: Integer; TargetID: LongWord; SourceID: LongWord; + SelectedSlot: Integer; + VoiceFrameCount: QWord; + VoiceErrorCount: QWord; end; TDMRDecoder = class; @@ -111,6 +117,9 @@ type FLastLCSlot: Integer; FColorCodeValid: Boolean; FColorCode, FLastDataType, FSlotTypeCorrectedBits: Integer; + FSelectedSlot: Integer; + FVoiceFrameCount, FVoiceErrorCount: QWord; + FOnAudio: TDMRAudioEvent; FBurstCount: QWord; FRMSSq: Double; @@ -127,6 +136,7 @@ type function Digitize(Phase: Integer; Symbol: Double): Byte; procedure StartBurstTracking(Phase: Integer); procedure CompleteBurst; + procedure DecodeVoiceBurst; procedure ResetDSP; public constructor Create; @@ -140,6 +150,7 @@ type class function SyncKindName(Kind: TDMRSyncKind): string; static; property Enabled: Boolean read FEnabled; + property OnAudio: TDMRAudioEvent read FOnAudio write FOnAudio; end; implementation @@ -275,6 +286,10 @@ begin FColorCode := 0; FLastDataType := 0; FSlotTypeCorrectedBits := 0; + FSelectedSlot := -1; + FVoiceFrameCount := 0; + FVoiceErrorCount := 0; + DMRMbeReset(FMbe); FTrackPhase := -1; FCurrentBurstPos := 0; FBurstCount := 0; @@ -480,11 +495,51 @@ begin FLastLCSlot := 1 else FLastLCSlot := 0; + if FSelectedSlot < 0 then FSelectedSlot := FLastLCSlot; end; + DecodeVoiceBurst; Inc(FBurstCount); FCurrentBurstPos := 0; 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; Inverted: Boolean); var @@ -559,6 +614,9 @@ begin S.ServiceOptions := FLastLinkControl.ServiceOptions; S.TargetID := FLastLinkControl.TargetID; S.SourceID := FLastLinkControl.SourceID; + S.SelectedSlot := FSelectedSlot; + S.VoiceFrameCount := FVoiceFrameCount; + S.VoiceErrorCount := FVoiceErrorCount; finally FStatusLock.Leave; end; diff --git a/DMRProtocol.pas b/DMRProtocol.pas index 452dc68..3c66862 100644 --- a/DMRProtocol.pas +++ b/DMRProtocol.pas @@ -3,9 +3,21 @@ unit DMRProtocol; { Small, allocation-free DMR burst protocol layer. - The CACH interleave and Hamming/Golay generator matrices are derived from - dsd-fme (src/dmr_bs.c and src/fec.c). The original code is distributed - under the ISC licence; see the dsd-fme source tree for its full notice. + The CACH/AMBE interleavers and Hamming/Golay matrices are derived from + dsd-fme (src/dmr_bs.c, src/fec.c and include/dmr_const.h). + + 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} @@ -25,6 +37,8 @@ type TDMRBit7 = array[0..6] of Byte; TDMRBit20 = array[0..19] of Byte; TDMRBit96 = array[0..95] of Byte; + TDMRAMBEFrame = array[0..95] of Byte; + TDMRAMBEFrames = array[0..2] of TDMRAMBEFrame; TDMRCACH = record Valid: Boolean; @@ -68,6 +82,10 @@ function DMRGolay208Encode(Value: Byte): TDMRBit20; procedure DMRBPTC19696Encode(const Payload: TDMRBit96; out Burst: TDMRDibitBurst); 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 DMRDecodeSlotType(const Burst: TDMRDibitBurst; out SlotType: TDMRSlotType): Boolean; @@ -110,6 +128,19 @@ const HAMMING_15_11_SYNDROME: array[0..14] of Byte = (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; var i, j: Integer; @@ -203,6 +234,49 @@ begin 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; out Corrected: Integer): Boolean; var diff --git a/RadioController.pas b/RadioController.pas index 853abaa..a170a9c 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -405,6 +405,7 @@ type // вызывающим (GUI или демон) после CreateEngines. procedure OnAudioReady(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 OnWaterfallReady(const Pixels: array of Single; Count: Integer); 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. FDMRDec := TDMRDecoder.Create; + FDMRDec.OnAudio := OnDMRAudioReady; // Audio out/in — объекты создаём сейчас, Open вызывается позже (после показа // формы / при подключении устройства). @@ -862,6 +864,13 @@ end; procedure TRadioController.FreeEngines; var i: Integer; 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 begin if FNetwork.Connected then FNetwork.Disconnect; @@ -883,7 +892,6 @@ begin FDSPEngine.Close; FreeAndNil(FDSPEngine); end; - if Assigned(FDMRDec) then FreeAndNil(FDMRDec); if Assigned(FBeaconDec) then FreeAndNil(FBeaconDec); end; @@ -1186,6 +1194,39 @@ begin FDMRDec.SetEnabled(False); 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) // --------------------------------------------------------------------------- diff --git a/native/dmr/README.md b/native/dmr/README.md index 27cd134..e2713f5 100644 --- a/native/dmr/README.md +++ b/native/dmr/README.md @@ -11,7 +11,11 @@ cmake -S native/dmr -B build/dmr -DCMAKE_BUILD_TYPE=Release cmake --build build/dmr ``` -The current ABI implements the stateful AMBE+2 vocoder boundary. The burst/FEC -layer will feed its row-major `char[4][24]` output to this library. Keep the ABI -versioned: the GUI and daemon must remain usable when the optional library is -not installed. +The Pascal burst/FEC layer extracts three row-major `char[4][24]` AMBE+2 +frames from each selected voice-slot burst and feeds them through this ABI. +Decoded 8 kHz mono PCM is resampled to EWSDR's 48 kHz speaker/web route. + +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. diff --git a/tests/dmr_frontend_test.pas b/tests/dmr_frontend_test.pas index ccea570..11bec84 100644 --- a/tests/dmr_frontend_test.pas +++ b/tests/dmr_frontend_test.pas @@ -9,6 +9,20 @@ uses const 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); var L, R: array of Single; @@ -95,9 +109,12 @@ end; var D: TDMRDecoder; S: TDMRStatus; + Sink: TAudioSink; begin + Sink := TAudioSink.Create; D := TDMRDecoder.Create; try + D.OnAudio := Sink.AudioReady; D.SetEnabled(True); FeedSync(D, BS_VOICE_SYNC, False); Sleep(50); @@ -124,8 +141,18 @@ begin D.SetInverted(False); D.SetEnabled(True); 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'); finally D.Free; + Sink.Free; end; end. diff --git a/tests/dmr_protocol_test.pas b/tests/dmr_protocol_test.pas index 187c546..bb15daf 100644 --- a/tests/dmr_protocol_test.pas +++ b/tests/dmr_protocol_test.pas @@ -76,6 +76,8 @@ var Burst: TDMRDibitBurst; Info: TDMRBurstInfo; Payload: TDMRBit96; + Frames: TDMRAMBEFrames; + VoiceDibits: array[0..107] of Byte; i: Integer; begin FillChar(Burst, SizeOf(Burst), 0); @@ -155,5 +157,20 @@ begin DMRParseBurst(Burst, Info); 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.