Patrick Lidstone
Self-hosted

D-STAR DV: native JARL digital-voice reception

A self-contained, clean-room transceiver for the JARL D-STAR DV (digital voice) mode: GMSK slicing on FM-discriminator audio, the 15-bit frame sync, the 660-bit coded radio header (rate-1/2 K=3 convolutional FEC + 12×55 block interleave + PRBS scramble, X.25-FCS-checked callsign fields), the 20 ms 96-bit voice frames (72 bits of full-rate AMBE + 24 bits of slow data), the 20-character slow-data TX message, and the every-21st-frame data-sync word — the whole receive chain from baseband to recovered callsigns, message text and AMBE parameter frames, no external decoder.

Rafe project · app/radio/dstar.py · in-repo NumPy


Abstract

D-STAR (Digital Smart Technologies for Amateur Radio) is the amateur digital-voice and data system defined by the JARL (Japan Amateur Radio League) and carried in Icom's amateur transceivers. Its DV (Digital Voice) mode is GMSK at 4800 bit/s in a 6.25 kHz channel: 3600 bit/s of FEC-protected AMBE voice plus a 1200 bit/s slow-data channel, framed 50 times a second. Each 20 ms on-air frame is 96 bits72 bits of full-rate AMBE channel data followed by 24 bits of slow data. A transmission opens with a bit-sync preamble and a fixed frame-sync word, sends a 660-bit coded radio header carrying the routing callsigns, then streams voice/data frames until a termination sequence.

This document specifies Rafe's native D-STAR DV receiver in full. Like every voice mode in the project it works on FM-discriminator audio: the GMSK signal's Gaussian-shaped NRZ baseband is recovered with a matched Gaussian filter and a maximum-eye-opening symbol-timing search and sliced directly (modulate / demodulate, app/radio/dstar.py); the IQ GMSK path for true SDR sources lives in app/radio/aisdecode.py. The radio header is descrambled (7-bit PRBS, x⁷+x⁴+1), de-interleaved (12×55 block), corrected with a four-state hard-decision Viterbi decoder for the (7,5) K=3 convolutional code, and its 39-byte callsign block is validated by a CRC-16/X.25 FCS (_fcs, reused from aisdecode.py). The slow-data channel reassembles the 20-character TX message from type-0x4x blocks de-scrambled with the published 0x70 0x4F 0x93 sequence; every 21st field is the published 0x55 0x2D 0x16 data-sync word. Each 72-bit voice frame is handed to the AMBE codec (ambe.channel_to_fr) → the native MBE vocoder for PCM. All callsign, message and framing metadata decode with the modem alone; only the final AMBE→PCM synthesis needs the vocoder.


1. Motivation and scope

Rafe decodes several digital-voice modes natively — P25 (p25.md), and the MBE/AMBE-family vocoders (imbe.md, ambe.md, mbe.md) — rather than shelling out to an external binary. dstar.py completes that story for D-STAR DV: a single module that takes discriminator audio to recovered callsigns, slow-data message and AMBE parameter frames (and, through the AMBE/MBE stack, to PCM) with nothing but NumPy.

The module is deliberately structured as a matched pair — a modulate_stream / decode_stream round trip — so every layer is exercised end-to-end by the test suite (§7). This is what lets the spec be honest about which pieces are bit-exact against the published D-STAR standard and which are structurally faithful and validated only against themselves (§8).

Scope. This document covers the DV mode only — 4800 bit/s GMSK voice + slow data. D-STAR's DD (Digital Data) mode — 128 kbit/s GMSK, data only, 23 cm band — is a different waveform and is out of scope and not implemented. The gateway / callsign-routing network layer (the DPlus/DExtra/G2 reflector protocols riding above the RF header) is likewise out of scope; this is a receiver for the on-air DV frame, not a gateway.


2. Background: real D-STAR

2.1 DV mode

D-STAR DV is GMSK — Gaussian minimum-shift keying — at 4800 bit/s with a Gaussian pulse of bandwidth-time product BT ≈ 0.5 and modulation index h = 0.5. Because GMSK is a form of continuous-phase FM, a DV signal can be received by an ordinary FM receiver and taken to baseband by its discriminator, exactly like the other voice modes here. The 4800 bit/s splits as:

  • 3600 bit/s voice — a 2400 bit/s full-rate AMBE parameter stream plus 1200 bit/s of Golay/scramble FEC, carried as 72 bits per 20 ms frame.
  • 1200 bit/s data — the slow-data channel, 24 bits per frame, carrying the 20-character TX message, GPS/APRS sentences, or the periodic sync word.

At 4800 bit/s a 96-bit frame is 20 ms, so DV runs 50 frames/s (72 bit ÷ 20 ms = 3600 bit/s voice; 24 bit ÷ 20 ms = 1200 bit/s data — confirmed in app/radio/dstar.py, FRAME = 96, BAUD = 4800).

2.2 The transmission and the radio header

A DV transmission is a fixed sequence: a bit-sync preamble (alternating 1010…), a frame-sync word, the radio header, then the voice+data frames, closed by a termination sequence. The radio header is the routing block: three flag bytes and four 8-character callsign fields —

  • RPT2 — the gateway / link repeater callsign,
  • RPT1 — the access (local) repeater callsign,
  • UR (YOUR) — the destination callsign, or CQCQCQ for a general call,
  • MY — the transmitting station's callsign, plus a 4-character suffix (e.g. an equipment tag) —

protected by a 16-bit CRC and then heavily FEC-coded (convolution + interleave + scramble) up to 660 on-air bits, because losing the header loses the whole call's routing.

2.3 Callsign routing

D-STAR is a routed network: the four callsign fields in the header let a repeater decide where a call goes. RPT1/RPT2 name the local repeater and its gateway; UR names either a specific station to route to (callsign routing) or CQCQCQ for an open call on the local/linked machine. This receiver surfaces all four fields plus the MY suffix; it does not act on them (no gateway).

2.4 DV vs DD

Two D-STAR air interfaces exist. DV (this document) is 4800 bit/s GMSK voice + slow data, on 2 m / 70 cm / 23 cm. DD is 128 kbit/s GMSK data-only, 23 cm only, with an Ethernet-like framing — a different modem entirely, not covered here.


3. Signal and symbol structure — exact numbers

Every value below is a constant in app/radio/dstar.py or directly derivable from one; the source symbol is cited.

Symbol layer

  • Bit (baud) rate: BAUD = 4800 bit/s (dstar.py). GMSK is 1 bit/symbol, so 4800 sym/s.
  • Default audio sample rate: fs = 48000 Hz → samples/bit sps = fs // BAUD = 10 (modulate / demodulate).
  • Gaussian pulse: _gauss(sps, bt=0.5) from aisdecode.py — a normalised Gaussian of span = 4 symbols, i.e. 40 taps at sps = 10, with σ = √(ln 2)/(2π·BT). This is both the transmit shaping filter and the receive matched filter (§4).

Framing layer (dstar.py)

  • Bit-sync preamble: PREAMBLE = [1,0]*3264 bits of alternating 1010….
  • Frame sync: FRAME_SYNC = 11101100010100015 bits.
  • Radio header: HEADER_BITS = 660 coded bits (see §3, header sizing below).
  • Voice/data frame: FRAME = 96 bits = 72 AMBE + 24 data.
  • Data-sync word: DATA_SYNC24 bits = bytes 0x55 0x2D 0x16 (published), inserted in the data field of every 21st voice frame.
  • Termination: END = [1,0]*16 + 000100110101110048 bits (16 bits of 1010… bit-reversal guard followed by the tail 0x13 0x5C).
  • Edge guard: modulate_stream brackets the whole bit stream with pad = [1,0]*8 (16 bits) front and back so the Gaussian filter's tails don't eat real bits.

Radio header sizing (build_header)

  3 flag bytes + RPT2(8) + RPT1(8) + UR(8) + MY(8) + suffix(4)  = 39 bytes  (312 bits)
  + 16-bit CRC (X.25 FCS)                                        = 41 bytes  (328 bits)
  + 2 convolutional flush bits                                   =           (330 bits)
  → rate-1/2 K=3 (7,5) convolutional encode                      →           (660 bits)
  → 12×55 block interleave → 7-bit PRBS scramble                 =  HEADER_BITS = 660

Voice layer

  • Full-rate AMBE channel frame: 72 bits per voice frame, produced/consumed by ambe.fr_to_channel / ambe.channel_to_fr (app/radio/ambe.py). Structure: Golay(24,12) over the 12-bit c0 word, a PN-scrambled Golay(24,12) over the 12-bit c1 word, then 24 unprotected bits (see ambe.md).
  • Underlying AMBE parameter frame: 48 bits (ambe.FR_BITS) — pitch(7) + band-voicing(5) + gain/spectral-amplitude bits, decoded by the native MBE vocoder.

Superframe

  • The k % 21 cadence in modulate_stream / decode_stream makes every 21st voice frame carry DATA_SYNC in its data field; the other 20 carry slow data. 21 × 20 ms = a 420 ms sync period (late-entry framing), matching real D-STAR.

3.1 Transmission structure (ASCII)

 edge      bit-sync      frame       radio header            voice / data frames  (×N)          termination   edge
 guard     preamble      sync                                                                                 guard
┌───────┬────────────┬───────────┬──────────────────┬──────────────────────────────────────┬─────────────┬───────┐
│[1,0]×8│ [1,0]×32   │ 15 bits   │  660 coded bits   │  96 bits each (20 ms):                │  END        │[1,0]×8│
│16 bits│ 64 bits    │ 11101100  │  39-byte hdr+CRC, │   ┌ frame k, k%21==0 → DATA_SYNC      │  48 bits    │16 bits│
│       │ 1010…      │ 0101000   │  conv+intlv+PRBS  │   └ frame k, else    → slow-data blk  │ (…0x13 5C)  │       │
└───────┴────────────┴───────────┴──────────────────┴──────────────────────────────────────┴─────────────┴───────┘
                                                          (assembled by modulate_stream / parsed by decode_stream)

3.2 The 96-bit voice frame (ASCII)

┌────────────────────────── 96-bit D-STAR DV frame (20 ms) ──────────────────────────┐
│           72 bits — full-rate AMBE channel frame            │      24 bits — data    │
│  ┌───────────────┬────────────────────┬──────────────────┐ │  ┌───────────────────┐ │
│  │  Golay24(c0)  │  Golay24(c1) ⊕ PN  │   24 raw bits    │ │  │ slow-data block   │ │
│  │    24 bits    │       24 bits       │     24 bits      │ │  │  OR  DATA_SYNC    │ │
│  └───────────────┴────────────────────┴──────────────────┘ │  └───────────────────┘ │
│        ↑ ambe.fr_to_channel(48-bit AMBE parameter frame)    │  ↑ every 21st = sync   │
└─────────────────────────────────────────────────────────────┴────────────────────────┘

4. GMSK modem on discriminator audio

Input to the receiver is real FM-discriminator audio at 48 kHz — the GMSK signal's Gaussian-shaped NRZ baseband. Output is a hard bit list. The modem is intentionally minimal because GMSK, once discriminated, is just filtered NRZ.

4.1 Modulate — modulate(bits, fs=48000)

For test-signal generation, modulate produces the baseband an FM modulator would be fed (dstar.py):

sps = fs // BAUD                     # 10
nrz = 2·bits − 1                     # {0,1} → {−1,+1}
up  = repeat(nrz, sps)               # hold each bit for sps samples
return convolve(up, _gauss(sps, bt=0.5), "same")   # Gaussian pulse-shape

There is no explicit frequency integration here — the signal is the shaped NRZ (the discriminator output of a GMSK signal), which is what every voice mode in this repo slices. The genuine IQ GMSK modulator (phase = cumulative-sum of the shaped NRZ, h = 0.5, exp(jφ)) lives in aisdecode.gmsk_modulate for SDR sources.

4.2 Demodulate — demodulate(audio, fs=48000)

sps = fs // BAUD                                     # 10
x   = convolve(audio, _gauss(sps, bt=0.5), "same")   # matched Gaussian filter
nsym = len(x) // sps

# maximum-eye-opening symbol-timing search:
best_off, best = 0, −1
for off in range(sps):                               # try all 10 sampling phases
    idx = off + arange(nsym)*sps ;  idx = idx[idx < len(x)]
    e   = mean(|x[idx]|)                              # energy at the sampled instants
    if e > best: best, best_off = e, off

idx = best_off + arange(nsym)*sps ;  idx = idx[idx < len(x)]
return [1 if v > 0 else 0 for v in x[idx]]           # zero-threshold slice

Two ideas, both shared with the P25 demod (p25/demod.py): a matched Gaussian filter maximises SNR at the sampling instant, and a max-|x| eye search picks the sampling phase whose samples land nearest the pulse peaks (the widest-open eye). There is no fractional interpolation or tracking loop — the phase is estimated once over the whole buffer, which is sufficient for the block-at-a-time decode (the modem round-trips exactly at clean SNR and under 2 % BER at 30 dB SNR — §7). Slicing is a plain sign test because discriminated GMSK is bipolar NRZ.


5. Decode — step by step

decode_stream(audio, fs=48000) orchestrates the whole receive chain (dstar.py):

bits = demodulate(audio, fs)
i    = _find(bits, FRAME_SYNC, max_err=1)            # frame-sync search (§5.1)
if i < 0: return None
p    = i + len(FRAME_SYNC)
header = parse_header(bits[p : p+HEADER_BITS])       # radio header (§5.2)
p += HEADER_BITS
# then the voice/data frame loop (§5.3–5.4)

5.1 Frame-sync search (_find, _match)

_match(bits, pat, max_err) is a Hamming-distance-tolerant compare; _find slides pat across the stream and returns the first index within max_err bits (dstar.py):

_match(bits, pat, max_err):  sum(a != b for a,b in zip(bits,pat)) <= max_err
_find(bits, pat, start=0, max_err=1):  first i with _match(bits[i:i+len(pat)], pat, max_err)

decode_stream searches for FRAME_SYNC (15 bits) with max_err=1. Exact match is not required — a couple of noise-flipped sync bits must not lose the whole call — but 15 bits with a 1-bit tolerance is far above the random-collision floor. The END termination is matched the same way but with max_err=4 (48-bit pattern), and the voice loop stops as soon as an END match is seen.

5.2 Radio header — descramble → de-interleave → Viterbi → CRC

parse_header(bits660) is the exact inverse of build_header (dstar.py):

dec = viterbi(_deinterleave(_scramble([int(b) for b in bits660])))
raw = _bits_to_bytes(dec[:328])                      # 41 bytes
crc_ok = _fcs(_bytes_to_bits(raw[:39])) == raw[39] | (raw[40] << 8)

Step by step:

  1. Descramble_scramble XORs the 660 coded bits with the output of a 7-bit LFSR, polynomial x⁷+x⁴+1, seeded 0x7F (_prbs, _scramble). XOR is its own inverse, so the same routine scrambles on TX and descrambles on RX:
    reg = 0x7F
    for each bit:  out_pn = (reg>>6)&1;  fb = ((reg>>6) ^ (reg>>3)) & 1;  reg = ((reg<<1)|fb) & 0x7F
  2. De-interleave_deinterleave reverses the 12×55 block interleave: the TX wrote 12 rows of 55 and read out column-major (_interleave), so RX reads the 660 bits back into out[r*55 + c] in the same column-major order (dstar.py).
  3. Viterbiviterbi is a four-state hard-decision decoder for the rate-1/2 K=3 (7,5) convolutional code (dstar.py). State = (s1,s2); for each state and input bit b it expects c0 = b^s1^s2 (G0 = 111) and c1 = b^s2 (G1 = 101), adds the Hamming distance to the received pair, keeps the best predecessor per next-state ns = (b<<1)|s1, and traces back from the minimum-metric state. Path metrics start [0, ∞, ∞, ∞] (known start state 0). 660 coded bits → 330 decoded; the first 328 are the header+CRC bytes, the last 2 are the flush.
  4. CRC — the recovered 39-byte callsign block is validated by the CRC-16/X.25 FCS _fcs (from aisdecode.py: poly 0x8408 reflected, init 0xFFFF, final XOR 0xFFFF), compared against the little-endian 16-bit CRC in bytes 39–40.

Field slicing from the 41-byte raw (parse_header):

 byte:  0 1 2 | 3 ............ 10 | 11 ........... 18 | 19 ........ 26 | 27 ....... 34 | 35 .. 38 | 39 40
 field: flags |     RPT2 (8)      |     RPT1 (8)      |    UR (8)      |    MY (8)     | suffix(4)|  CRC

Each callsign field is .decode("ascii","replace").strip(); crc_ok gates whether the fields are trustworthy. The build side (build_header, _cs8) upper-cases, left-justifies and pads each field with spaces to its fixed width before CRC + FEC.

5.3 Voice/data frame loop

After the header, decode_stream walks 96-bit frames (dstar.py):

k = 0
while p + FRAME <= len(bits):
    if _match(bits[p:p+len(END)], END, 4):  break     # termination
    f48, _ = ambe.channel_to_fr(bits[p : p+72])       # 72 AMBE bits → 48-bit param frame
    frames.append(f48)
    if k % 21:                                        # not a sync frame
        datafields.append(bits[p+72 : p+96])          # 24 slow-data bits
    p += FRAME ;  k += 1
return {"header": header, "frames": frames,
        "message": decode_message(datafields), "nframes": len(frames)}

The k % 21 test mirrors the encoder: frames where k % 21 == 0 carried DATA_SYNC and are skipped for message reassembly; all other frames' 24-bit data fields are collected for decode_message. Every frame's 72 voice bits go to AMBE regardless.

5.4 Voice frame → AMBE → PCM

Each 72-bit channel frame is decoded by ambe.channel_to_fr(bits72) (app/radio/ambe.py), which:

  • Golay(24,12)-decodes the first 24 bits to recover the 12-bit c0 word (the seed for the PN scrambler);
  • regenerates the 24-bit PN sequence from c0 (15-bit LFSR, taps 14/13, seeded c0 or 0xFFF), XORs it off bits 24–47 and Golay(24,12)-decodes to c1;
  • returns the 48-bit AMBE parameter frame c0(12) ‖ c1(12) ‖ raw(24) and a corrected-bit count.

That 48-bit parameter frame → ambe.decode_frame_fr → the native MBE vocoder (ambe.synthesize_fr) yields PCM. The AMBE channel-frame layout, Golay codes and PN structure are documented in AMBE; the DV receiver simply routes the 72-bit field there. Callsigns, message and framing all decode without the vocoder — only audio needs it.

5.5 Slow-data message reassembly

The 20-character TX message rides in the slow-data channel as type-0x4x blocks. Encode (encode_message, dstar.py):

text = text.ljust(20)[:20]                            # exactly 20 chars
blocks = [ bytes([0x40 | i]) + text[i*5:(i+1)*5]  for i in range(4) ]   # 4 blocks: type byte + 5 chars
blocks += [b"\x66"*6] * (data_frames//2 − 4)          # 6 filler blocks (0x66…)
for blk in blocks:                                    # each 6-byte block → two 24-bit fields
    fields += bits(_sd_scramble(blk[0:3]))            # first 3 bytes, scrambled
    fields += bits(_sd_scramble(blk[3:6]))            # last 3 bytes, scrambled

A 20-char message is four 5-char segments; block i has type byte 0x40 | i (so 0x40..0x43, the D-STAR text-message type, index in the low nibble) followed by segment i. Ten blocks × two half-block fields = 20 data fields per superframe. _sd_scramble XORs each 3-byte half against the published sequence 0x70 0x4F 0x93 and is involutive (XOR).

Decode (decode_message) reverses it: it pairs consecutive fields, de-scrambles both halves back to a 6-byte block, and for blocks whose type byte is 0x4x with low nibble < 4 places the five ASCII payload bytes at msg[i*5 .. i*5+4]. Filler 0x66 blocks fail the 0x4x test and are ignored; the result is the assembled, right-stripped 20-character message.


6. Constants and tables

All values quoted verbatim from app/radio/dstar.py unless noted.

6.1 Framing constants

BAUD        = 4800
PREAMBLE    = [1,0]*32                                 # 64-bit bit-sync
FRAME_SYNC  = [1,1,1,0,1,1,0,0,0,1,0,1,0,0,0]          # 15 bits
END         = [1,0]*16 + [0,0,0,1,0,0,1,1,0,1,0,1,1,1,0,0]     # 48 bits (tail 0x13 0x5C)
DATA_SYNC   = [0,1,0,1,0,1,0,1, 0,0,1,0,1,1,0,1, 0,0,0,1,0,1,1,0]   # 24 bits = 0x55 0x2D 0x16
HEADER_BITS = 660
FRAME       = 96                                       # 72 voice + 24 data

6.2 Radio-header FEC

convolutional code : rate 1/2, K=3, generators (7,5) octal
                     G0 = 111 → out bit b^s1^s2 ;  G1 = 101 → out bit b^s2   (conv_encode)
Viterbi            : 4 states, hard-decision, start-state 0, metric init [0,∞,∞,∞]   (viterbi)
interleave         : 12 rows × 55 cols, write row-major, read column-major   (_interleave)
scramble PRBS      : x⁷ + x⁴ + 1, 7-bit LFSR, seed 0x7F, output = bit 6      (_prbs, _scramble)
header CRC         : CRC-16/X.25 FCS (poly 0x8408 reflected, init 0xFFFF,
                     final XOR 0xFFFF), little-endian, over the 39 data bytes  (_fcs, aisdecode.py)

6.3 Radio-header field map

raw = flags(3) ‖ RPT2(8) ‖ RPT1(8) ‖ UR(8) ‖ MY(8) ‖ suffix(4)   = 39 bytes
    ‖ CRC-lo, CRC-hi                                              = 41 bytes = 328 bits
build_header: conv_encode( bits(raw) + [0,0] )  →  660  →  _interleave  →  _scramble
_cs8(s, n): s.upper().ljust(n)[:n] as ASCII                       # fixed-width, space-padded

6.4 Slow-data constants

_SCRAMBLE_BYTES = (0x70, 0x4F, 0x93)                  # published; _sd_scramble XOR, involutive
text-message block: bytes([0x40 | i]) + 5 ASCII chars, i = 0..3   # type 0x4x, segment index low nibble
filler block       : b"\x66" * 6                                   # ignored by decode_message
data-sync word     : DATA_SYNC = 0x55 0x2D 0x16 (published), inserted when k % 21 == 0
superframe cadence : every 21st voice frame carries DATA_SYNC → 420 ms sync period

6.5 AMBE full-rate channel frame (see ambe.md)

ambe.fr_to_channel(bits48) → 72 bits :  Golay24(c0) ‖ [Golay24(c1) ⊕ PN(c0)] ‖ raw(24)
ambe.channel_to_fr(bits72) → (bits48, nerr)   # Golay-corrected, PN from recovered c0
FR_BITS = 48   CHANNEL_BITS = 72   PN: 15-bit LFSR, taps 14/13, seed (c0 & 0xFFF) or 0xFFF

7. Interoperability and validation

7.1 The test suite (test_dstar.py)

Seven tests exercise every native layer; all use fixed RNG seeds and are self-consistent (encode → corrupt → decode):

test what it proves key assertions
test_gmsk_modem_roundtrip Gaussian modem end-to-end modulate → demodulate exact on interior bits [8:392]; < 2 % BER after AWGN to 30 dB SNR
test_conv_viterbi_corrects (7,5) K=3 code + Viterbi 100 data bits conv_encoded, 6 spread bit errors injected, viterbi recovers the data exactly
test_header_roundtrip_with_errors radio header FEC + CRC 660-bit header, 8 random bit flips, parse_header gives crc_ok and exact MY/UR/RPT1/RPT2/suffix
test_header_crc_rejects_heavy_corruption CRC rejects garbage 80 random bit flips → crc_ok is False (no false-accept)
test_slow_data_message slow-data message codec 20-char message → 20 fields × 24 bits → exact round-trip; _sd_scramble shown involutive
test_full_stream_end_to_end whole DV transmission 42 AMBE frames (two superframes) + message → modulate_streamdecode_stream: crc_ok, MY exact, nframes == 42, frames identical, message exact
test_stream_through_noise robustness same stream + AWGN to 30 dB SNR: header crc_ok, MY exact, ≥ 38/42 voice frames recovered intact

The modem test is the strongest evidence the demod is correct: 400 random bits, AWGN to 30 dB SNR (n·√(mean n²)/10^1.5), recovered BER under 2 %; the interior bits are exact at clean SNR. The full-stream test proves the layers compose — header FEC, the 21-frame sync cadence, AMBE channel-frame round-trip, and slow-data reassembly all interlock through one modulate_stream/decode_stream pass.

7.2 On-air reference

The self-consistency tests validate the transcription of the FEC, framing and scramblers against themselves. The published, bit-exact pieces — the K=3 (7,5) convolutional code, the X.25 FCS header CRC, the 0x70 0x4F 0x93 slow-data scrambler and the 0x55 0x2D 0x16 data-sync word — are directly comparable to the JARL D-STAR specification. The interop-flagged pieces (§8) would be confirmed against a real off-air DV capture or an external reference decoder (e.g. dsd-fme / mbelib, the same reference the project uses for P25 — app/radio/digivoice.py), which has not yet been done for this native path. AMBE→PCM audio quality is bounded by the AMBE/MBE codec's own limitations (ambe.md §Limitations).


8. Limitations

Stated honestly, roughly in order of impact. The module docstring draws the line: "header layout/CRC, the K=3 (7,5) code, the slow-data scrambler bytes and data-sync word are the published values. The interleave geometry, LFSR seed, sync/termination bit patterns and the in-frame AMBE bit interleave are structurally faithful, self-consistent, and flagged for on-air interop."

  • Sync / termination patterns are interop-flagged. The 15-bit FRAME_SYNC, the 64-bit PREAMBLE and the 48-bit END are structurally faithful and self-consistent between modulate_stream and decode_stream, but the exact on-air sync/termination bit patterns are not guaranteed bit-identical to real D-STAR. A real DV receiver keyed to the genuine header/frame sync would not lock to this stream without confirming these words.
  • Header interleave geometry and PRBS seed. The 12×55 block-interleave shape and the 0x7F PRBS seed reproduce the structure of the D-STAR header scramble (the x⁷+x⁴+1 polynomial is the published one), but the precise interleave map and seed are flagged for on-air interop, not verified against a real header.
  • AMBE channel-frame is not bit-exact voice. ambe.fr_to_channel / channel_to_fr reproduce the published full-rate AMBE channel structure (two Golay24 + PN scramble), but DVSI's exact VQ quantiser tables, the true PN seed constant and inter-frame prediction are not transcribed — so synthesized audio is intelligible-but-approximate, not bit-exact against a real AMBE stream (see ambe.md, mbe.md).
  • DV only; no DD. Only the 4800 bit/s GMSK voice+slow-data mode is implemented. D-STAR DD (128 kbit/s, 23 cm data) is a different waveform and is out of scope.
  • No gateway / routing layer. The receiver surfaces the four callsign fields and MY suffix but does not act on callsign routing, and it does not implement the DPlus/DExtra/G2 reflector network protocols above the RF header.
  • Slow-data: TX message only. decode_message reassembles the 20-character type-0x4x text message; other slow-data payloads (GPS/APRS $$CRC… sentences, fast-data, DPRS) are carried transparently in datafields but not parsed.
  • Block-at-a-time timing, no tracking. Symbol timing is a one-shot max-eye search over the whole buffer with no fractional interpolation or per-symbol tracking loop (demodulate); fine for clean captures, weaker on drifting or very faint signals. There is no discriminator-audio IQ front end here — that path is in aisdecode.py.

None of these change the architecture; they are the natural next increments on a working native receive chain.


9. References

  1. JARL D-STAR system specification — the DV mode: 4800 bit/s GMSK, the radio header layout and CRC, the rate-1/2 K=3 (7,5) convolutional code, the 12×55 interleave and x⁷+x⁴+1 header scramble, the 96-bit voice/data frame, the slow-data channel with the 0x70 0x4F 0x93 scrambler and the 0x55 0x2D 0x16 data-sync word (cited throughout app/radio/dstar.py).
  2. JARL D-STAR AMBE full-rate voice — the 48-bit parameter frame carried as a 72-bit channel frame (two Golay(24,12) + PN scramble); see the in-repo AMBE parameter codec and MBE vocoder.
  3. In-repo implementation: app/radio/dstar.py (modem, header, slow data, stream assembly); tests in test_dstar.py.
  4. Reused primitives: app/radio/aisdecode.py_fcs (CRC-16/X.25 FCS, the header CRC) and _gauss (the Gaussian pulse for the modem). The IQ GMSK path for true SDR sources (gmsk_modulate / gmsk_demod_data, phase-integrated exp(jφ)) also lives there — see AIS.
  5. app/radio/ambe.py — the full-rate AMBE channel codec (fr_to_channel / channel_to_fr); app/radio/mbe.py — the native MBE analysis/synthesis vocoder.
  6. Companion specs: P25 Phase 1 (the sibling native digital-voice receiver and the depth/house-voice benchmark), RVQ-Voice (the template document).