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 bits — 72 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
CQCQCQfor 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 = 4800bit/s (dstar.py). GMSK is 1 bit/symbol, so 4800 sym/s. - Default audio sample rate:
fs = 48000Hz → samples/bitsps = fs // BAUD = 10(modulate/demodulate). - Gaussian pulse:
_gauss(sps, bt=0.5)fromaisdecode.py— a normalised Gaussian ofspan = 4symbols, 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]*32— 64 bits of alternating 1010…. - Frame sync:
FRAME_SYNC = 111011000101000— 15 bits. - Radio header:
HEADER_BITS = 660coded bits (see §3, header sizing below). - Voice/data frame:
FRAME = 96bits = 72 AMBE + 24 data. - Data-sync word:
DATA_SYNC— 24 bits = bytes0x55 0x2D 0x16(published), inserted in the data field of every 21st voice frame. - Termination:
END = [1,0]*16 + 0001001101011100— 48 bits (16 bits of 1010… bit-reversal guard followed by the tail0x13 0x5C). - Edge guard:
modulate_streambrackets the whole bit stream withpad = [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-bitc0word, a PN-scrambled Golay(24,12) over the 12-bitc1word, 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 % 21cadence inmodulate_stream/decode_streammakes every 21st voice frame carryDATA_SYNCin 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:
- Descramble —
_scrambleXORs the 660 coded bits with the output of a 7-bit LFSR, polynomial x⁷+x⁴+1, seeded0x7F(_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 - De-interleave —
_deinterleavereverses 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 intoout[r*55 + c]in the same column-major order (dstar.py). - Viterbi —
viterbiis 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 expectsc0 = b^s1^s2(G0 = 111) andc1 = b^s2(G1 = 101), adds the Hamming distance to the received pair, keeps the best predecessor per next-statens = (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. - CRC — the recovered 39-byte callsign block is validated by the CRC-16/X.25 FCS
_fcs(fromaisdecode.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
c0word (the seed for the PN scrambler); - regenerates the 24-bit PN sequence from
c0(15-bit LFSR, taps 14/13, seededc0or0xFFF), XORs it off bits 24–47 and Golay(24,12)-decodes toc1; - 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_stream → decode_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-bitPREAMBLEand the 48-bitENDare structurally faithful and self-consistent betweenmodulate_streamanddecode_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
0x7FPRBS 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_frreproduce 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_messagereassembles the 20-character type-0x4xtext message; other slow-data payloads (GPS/APRS$$CRC…sentences, fast-data, DPRS) are carried transparently indatafieldsbut 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 inaisdecode.py.
None of these change the architecture; they are the natural next increments on a working native receive chain.
9. References
- 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 0x93scrambler and the0x55 0x2D 0x16data-sync word (cited throughoutapp/radio/dstar.py). - 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.
- In-repo implementation:
app/radio/dstar.py(modem, header, slow data, stream assembly); tests intest_dstar.py. - 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-integratedexp(jφ)) also lives there — see AIS. 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.- Companion specs: P25 Phase 1 (the sibling native digital-voice receiver and the depth/house-voice benchmark), RVQ-Voice (the template document).