YSF (Yaesu System Fusion): native C4FM DN-mode reception
A self-contained, clean-room receiver for Yaesu System Fusion's digital (C4FM) air interface in its DN mode (V/D type 2): the 4-level FSK demodulator reused from P25, the 40-bit frame sync, the CRC → Golay → convolution → interleave FICH (Frame Information CHannel) coding chain, the whitened convolutional DCH callsign block, and the five AMBE+2 half-rate voice frames per 100 ms frame — routed to the native AMBE parameter codec and MBE vocoder so the whole voice path runs without a DVSI chip or mbelib.
*Rafe project · app/radio/ysf.py · in-repo NumPy (reuses app/radio/p25/demod.py
p25/fec.pyandapp/radio/ambe.py)*
Abstract
Yaesu System Fusion (YSF / C4FM Digital) is Yaesu's amateur digital-voice system. Like APCO P25 Phase 1 it transmits continuous-phase 4-level FSK — "C4FM" — at 4800 four-level symbols per second, two bits per symbol, 9600 bit/s gross, in a 12.5 kHz FDMA channel. Above the symbol layer YSF frames a rigid 100 ms / 960-bit structure: a fixed 40-bit frame sync, a 200-bit FICH carrying the frame-type/addressing/sequence metadata, and a 720-bit payload. In its DN mode (Digital Narrow, the V/D "type 2" mode that mixes voice and data), the payload is five interleaved DCH/VCH pairs — five 72-bit Data Channel slots carrying a whitened, convolutionally-coded 20-character CSD (Common Sequential Data / callsign) block, and five 72-bit Voice Channel slots each an AMBE+2 half-rate channel frame.
This document specifies Rafe's native YSF DN-mode receiver in full. The demodulator is the P25 C4FM modem reused unchanged (p25/demod.py, SYMRATE = 4800); the framer correlates the 40-bit sync and undoes the FICH coding chain — a 20×10 deinterleave, a hard-decision Viterbi for the rate-1/2 constraint- length-5 code, four Golay(24,12) decodes and a CRC-16 check; the CSD path undoes PN whitening, a 20×18 deinterleave, the same Viterbi and a CRC; and each voice slot is a 72-bit AMBE+2 channel frame handed to the AMBE codec → MBE vocoder. Everything the receiver needs to surface call metadata — frame type, callsigns, sequence numbers — decodes with the modem alone; no vocoder is required for that. The FICH's Golay code and the whole 4-level modem are literally the P25 primitives; the K=5 convolution and Viterbi are shared with NXDN.
1. Motivation and scope
Rafe already bridges to YSF the way it bridges to the other DV modes — through an external stack. The native app/radio/ysf.py module moves the whole receive chain in-house: 4-level baseband → FICH metadata, CSD callsigns and AMBE parameter frames, with nothing external left once the AMBE codec + MBE vocoder synthesise the voice:
NATIVE (app/radio/ysf.py + p25/demod, p25/fec, radio/ambe)
FM-disc audio ─▶ C4FM demod ─▶ sync search ─▶ FICH ─▶ frame-type / seq metadata ┐
(48 kHz) p25/demod.py ysf.py ysf.py (fi/fn/ft/dt) │
├─▶ DCH×5 ─▶ dewhiten ─▶ Viterbi ─▶ CSD callsigns
│ ysf.parse_dch
└─▶ VCH×5 ─▶ AMBE+2 channel ─▶ AMBE codec ─▶ MBE vocoder ─▶ PCM
ambe.channel_to_hr ambe.py mbe.py
The module reuses P25's DSP and FEC wholesale — the C4FM modem (p25/demod.py, imported as from .p25 import demod) and the Golay(24,12) codec (p25/fec.py) — and shares its own K=5 convolution/Viterbi with the NXDN decoder. The voice slots are AMBE+2 half-rate channel frames, exactly the DMR/NXDN(EHR) 20 ms frame, so radio/ambe.py serves all three.
This spec covers DN mode (V/D type 2) only — the mixed voice+data mode that is System Fusion's default and the one the code implements end-to-end. The Voice-FR ("Wide", full-rate voice) and Data-FR modes are C4FM but out of scope; see §2.
2. Background
2.1 C4FM FDMA — shared with P25
YSF is FDMA: one conversation per 12.5 kHz RF channel, modulated with C4FM — continuous-phase 4-level frequency-shift keying. Each dibit (2 bits) maps to one of four nominal deviations; the stream is Nyquist-shaped so the occupied bandwidth fits the channel. Because it is plain FM at RF, a YSF signal can be received by any FM receiver and demodulated to a 4-level baseband by an ordinary FM discriminator — the input this implementation expects. It is bit-for-bit the same waveform layer as P25 Phase 1, which is why ysf.py imports the P25 demodulator unchanged (from .p25 import demod, SYMRATE = 4800). See P25 §2.1 and §4 for the modem in detail; the level/deviation/slice maps below are P25's.
Two bits per symbol × 4800 symbols/s = 9600 bit/s gross channel rate.
2.2 The System Fusion C4FM modes
Real System Fusion multiplexes several digital modes on the same C4FM waveform, selected by the FICH DT (Data Type) field:
| DT | Mode | Content |
|---|---|---|
| 0 | V/D mode 1 | half-rate voice + half-rate data, both FEC-heavy |
| 1 | Data FR | full-rate data (no voice) |
| 2 | V/D mode 2 (DN) | half-rate voice + the CSD data channel — the mode implemented here |
| 3 | Voice FR | full-rate ("Wide", VW) voice — the whole payload is voice |
A YSF radio also runs AMS (Automatic Mode Select), switching between analog FM and these digital modes per transmission. This implementation targets DN (DT = 2): five 20 ms AMBE+2 voice frames plus the CSD callsign channel per 100 ms frame. modulate_dn sets dt = 2 in every FICH it builds (ysf.py).
2.3 The 100 ms frame
Every YSF frame is a rigid concatenation of three fields (ysf.py, FRAME_BITS):
[ 40-bit frame sync ] [ 200-bit FICH ] [ 720-bit payload ]
- Frame sync — a single fixed 40-bit pattern
SYNC = 0xD471C9634Dthat marks the start of every frame and locks symbol/frame timing (ysf.py,SYNC). - FICH (Frame Information CHannel) — 200 transmitted bits protecting 32 info bits (the frame-type, addressing and sequence fields) with CRC-16, four Golay(24,12) codewords, a rate-1/2 convolution and a 20×10 interleave (§5.2).
- Payload — 720 bits. In DN mode, five DCH/VCH pairs (§4): a whitened convolutional CSD callsign block spread across five 72-bit DCH slots, interleaved with five 72-bit AMBE+2 voice frames.
At 9600 bit/s, 960 bits = 100 ms, i.e. 480 C4FM symbols. Five 20 ms voice frames fit exactly in one 100 ms YSF frame.
2.4 FICH, DCH, VCH — the three logical channels
- FICH — the "header" of every frame: what kind of frame is this, who is addressed, where in the sequence are we. A receiver reads FICH with the modem + Golay + CRC alone, no vocoder, to follow a call.
- DCH (Data Channel) — in DN mode this carries the CSD (Common Sequential Data) block: 20 characters of destination + source callsign text, heavily protected (convolution + CRC + whitening) and repeated frame-to-frame so a receiver that joins mid-call still recovers the callsigns.
- VCH (Voice Channel) — the AMBE+2 half-rate voice, five 20 ms frames per YSF frame, each a 72-bit channel frame (§5.4).
3. Signal and symbol structure — exact numbers
Everything here is a constant in the code or derivable from one; the source module is cited.
Symbol layer (p25/demod.py, reused unchanged)
- Symbol (baud) rate:
SYMRATE = 4800sym/s. Bits per symbol: 2. Gross bit rate 9600 bit/s. - Four levels {+3, +1, −1, −3} from
_LEVEL = {1: 3, 0: 1, 2: -1, 3: -3}(dibit-value → level); nominal deviations ±1800 Hz (outer ±3) / ±600 Hz (inner ±1). - Default audio rate
fs = 48000→ samples/symbolsps = fs // SYMRATE = 10. - Matched/shaping filter: root-raised-cosine, roll-off α = 0.2, span 8 symbols → 161 taps, unit-energy normalised.
See P25 §3–4 for the demod internals (RRC, max-eye timing search, amplitude normalisation, 4-level slice). ysf.decode calls demod.dibits_to_bits(demod.demodulate(audio, fs)) (ysf.py, decode) and modulate_dn calls demod.modulate(...) (ysf.py, modulate_dn).
Frame layer (ysf.py)
- Frame sync: 40 bits,
SYNC = 0xD471C9634D;SYNC_BITSis the MSB-first bit array[(SYNC >> (39 - i)) & 1 for i in range(40)](ysf.py). - Frame length:
FRAME_BITS = 960= 40 sync + 200 FICH + 720 payload (ysf.py). - Sync-search tolerance:
decode(..., max_err=3)accepts a 40-bit window whose Hamming distance toSYNC_BITSis ≤ 3 (ysf.py,decode).
FICH layer (ysf.py)
- Info fields: 26 bits across ten fields (
_FICH_FIELDS), zero-padded to 32 info bits (build_fich). - CRC-16 → 48 bits → 4 × Golay(24,12) → 96 bits → + 4 tail bits → rate-1/2 K=5 convolution → 200 bits → 20×10 interleave → 200 transmitted bits (
build_fich).
- CRC-16 → 48 bits → 4 × Golay(24,12) → 96 bits → + 4 tail bits → rate-1/2 K=5 convolution → 200 bits → 20×10 interleave → 200 transmitted bits (
DCH / CSD layer (ysf.py)
- CSD block: 20 ASCII characters = 160 data bits (
build_dch). - CRC-16 (16) + 4 tail → 180 → rate-1/2 K=5 convolution → 360 bits → 20×18 interleave → PN whitening → 360 whitened DCH bits (
build_dch), split across the five 72-bit DCH slots (build_frame).
- CRC-16 (16) + 4 tail → 180 → rate-1/2 K=5 convolution → 360 bits → 20×18 interleave → PN whitening → 360 whitened DCH bits (
Voice layer (ambe.py)
- AMBE+2 half-rate parameter frame:
HR_BITS = 49bits →CHANNEL_BITS = 72bit channel frame viahr_to_channel(ambe.py); five per YSF frame.
Payload geometry (ysf.py, build_frame / parse_frame)
- 720 payload bits = 5 × (72 DCH + 72 VCH). DCH slot k =
dch[72k : 72k+72]; VCH slot k =ambe.hr_to_channel(ambe5[k]). On RX, slot k starts at bit240 + k·144: DCH is[s : s+72], VCH is[s+72 : s+144].
4. The 960-bit frame
│◀──────────────── 100 ms · 960 bits · 480 C4FM symbols ────────────────▶│
┌──────────────┬────────────────────┬────────────────────────────────────┐
│ 40 SYNC │ 200 FICH │ 720 payload │
│ 0xD471C9634D │ Frame Information │ 5 × (72-bit DCH + 72-bit VCH) │
│ (MSB-first) │ CHannel │ │
└──────────────┴────────────────────┴────────────────────────────────────┘
bit 0 40 240 960
The 720-bit DN payload interleaves the data channel and the voice channel slot by slot (ysf.py, build_frame):
payload = D0 V0 D1 V1 D2 V2 D3 V3 D4 V4 (each block = 72 bits)
│ DCH slot k = dch[72k : 72k+72]
│ VCH slot k = AMBE+2 channel frame k
└── the five 72-bit DCH slots, concatenated in order, are the
360-bit whitened DCH (CSD) block → parse_dch()
└── each 72-bit VCH slot is an independent AMBE+2 channel
frame → ambe.channel_to_hr()
So the DCH is spread across the frame (one 72-bit chunk between each pair of voice frames), giving time diversity for the callsign data; the five voice frames are independent. parse_frame walks k = 0…4, accumulating the DCH chunks into a 360-bit block and decoding each VCH slot separately (ysf.py, parse_frame).
The FICH coding chain (build_fich / parse_fich):
FICH fields (fi,cs,cm,bn,bt,fn,ft,dt,sql,sq) = 26 bits, zero-padded to 32
│ append CRC-16 (poly 0x1021, init 0xFFFF, MSB-first)
▼
48 info bits = 32 info + 16 CRC
│ split into 4 × 12-bit groups
▼
4 × Golay(24,12) → 96 coded bits (p25/fec.golay24_encode)
│ append 4 tail bits (trellis termination)
▼
rate-1/2 K=5 convolution (G0=0o23, G1=0o35) → (96+4)·2 = 200 bits
│ 20×10 block interleave (write rows, read columns)
▼
200 transmitted FICH bits
Decode is the mirror: 20×10 deinterleave → Viterbi (K=5) → drop the 4 tail bits → four Golay(24,12) decodes → CRC-16 check on the 32 info bits (ysf.py, parse_fich).
5. Decode — step by step
Input: real FM-discriminator audio at 48 kHz. decode (ysf.py) demodulates to a bit stream, then repeatedly finds a sync and parses the 960-bit frame after it.
5.1 Frame sync search (ysf.py, decode)
decode demodulates once, then slides a 40-bit window across the bit stream from pos, returning the first index i whose Hamming distance to SYNC_BITS is ≤ max_err (default 3):
bits = demod.dibits_to_bits(demod.demodulate(audio, fs))
for j in range(pos, n - 39):
if sum(bits[j+k] != SYNC_BITS[k] for k in range(40)) <= max_err:
i = j; break
If i < 0 or i + FRAME_BITS > n the loop ends. Otherwise parse_frame(bits[i : i+960]) is attempted; on success the result is appended and the cursor advances to pos = i + FRAME_BITS. Exact sync match is deliberately not required — C4FM noise flips a few sync bits, and 40 bits of fixed pattern give a wide margin over the random-collision floor.
5.2 FICH: deinterleave → Viterbi → Golay → CRC (ysf.py, parse_fich)
The 200 FICH bits (frame bits 40…240) are decoded by parse_fich:
- 20×10 deinterleave.
_deinterleave(bits200, 20, 10)inverts the write-rows/read-columns interleave, restoring the 200 coded bits in convolutional order. - Viterbi (K=5).
viterbi(...)is a hard-decision 16-state Viterbi for the rate-1/2 constraint-length-5 code (§6.3), returning 100 decoded bits (96 Golay bits + 4 tail; the tail is simply the trailing terminated state and is ignored). - Four Golay(24,12) decodes. For
i = 0…3,fec.golay24_decodecorrects the 24-bit codeworddec[24i : 24i+24]to 12 data bits; any failed decode (None) rejects the whole frame (returnsNone). - CRC-16. The 48 recovered info bits split as 32 info + 16 CRC; if
_crc16(info48[:32]) != _frombits(info48[32:48])the frame is rejected. - Field slice. The 32 info bits are sliced back into the ten FICH fields per
_FICH_FIELDS(the trailing 6 pad bits are ignored) and returned as a dict.
A frame whose FICH fails CRC or Golay is dropped entirely (parse_frame returns None when parse_fich does), so a bad FICH takes the payload with it.
5.3 DCH / CSD: dewhiten → deinterleave → Viterbi → CRC (ysf.py, parse_dch)
The five 72-bit DCH slots are concatenated (in slot order) into a 360-bit block by parse_frame, then decoded by parse_dch:
- De-whiten.
_whiten(bits360)XORs the PN sequence off the 360 bits. The whitening is a self-synchronous PN from a 7-bit LFSR (_prbs, seed 0x7F, polynomial x⁷ + x⁴ + 1, §6.4) and is involutive — applying_whitena second time on TX and RX is the identity (asserted in the test suite, §7). - 20×18 deinterleave.
_deinterleave(..., 20, 18)restores the 360 coded bits in convolutional order. - Viterbi (K=5). Same decoder as FICH, on 360 bits → 180 decoded bits.
- Split + CRC.
data = dec[:160],crc = _frombits(dec[160:176])(the 4 tail bitsdec[176:180]are discarded). The 160 data bits are read back as 20 ASCII bytes;crc_ok = (_crc16(data) == crc).
parse_dch returns (text, crc_ok); parse_frame surfaces these as csd and csd_ok. In a DN call built by modulate_dn, the CSD is dst.ljust(10)[:10] + src.ljust(10)[:10] — destination callsign in the first 10 chars, source in the next 10 (ysf.py, modulate_dn).
5.4 Voice: the AMBE+2 channel frame (ysf.py + ambe.py)
Each 72-bit VCH slot is an AMBE+2 half-rate channel frame. parse_frame calls ambe.channel_to_hr(bits960[s+72 : s+144]) for each slot, recovering the 49-bit AMBE+2 parameter frame (ysf.py, parse_frame). Inside ambe.py, the channel frame is (_from_channel, c1_golay24=False):
- bits 0–23: Golay(24,12) over the 12 most-sensitive parameter bits (
c0) — decoded first, because it seeds the descrambler; - bits 24–46: a PN-scrambled Golay(23,12) over the next 12 bits (
c1) — the PN is a 15-bit LFSR seeded from the correctedc0(ambe._pn), the same PN the P25 IMBE voice FEC uses; - bits 47–71: 25 unprotected bits.
The recovered 49-bit parameter frame (7-bit log-pitch, 5-bit band voicing, gain + differential-log spectral amplitudes) drives the MBE vocoder → PCM — fully native, no DVSI/mbelib. This is the identical 20 ms voice frame used by DMR and NXDN (EHR); see AMBE § for the parameter layout and the channel-frame FEC, and MBE for synthesis. parse_frame keeps only the 49-bit parameter frames (frames); the corrected-bit count from channel_to_hr is discarded here.
5.5 Assembling a frame (ysf.py, parse_frame)
parse_frame(bits960) returns {"fich": …, "csd": …, "csd_ok": …, "frames": […5 × 49-bit…]} or None if the FICH failed. decode collects these across the whole buffer. A DN call therefore surfaces, per 100 ms frame: the frame type (fich["fi"]) and sequence (fich["fn"]/["ft"]), the two callsigns (csd, guarded by csd_ok), and five AMBE+2 voice parameter frames.
5.6 Transmit path (ysf.py, modulate_dn / build_frame)
modulate_dn(dst, src, ambe_frames, fs, sq) is the inverse for test/loopback. It requires len(ambe_frames) % 5 == 0, builds one CSD (dst+src, 20 chars), and for each group of 5 AMBE frames emits one 960-bit frame with:
fi(Frame Information) = 0 for the first frame (header), 2 for the last (terminator), 1 for the middle frames (communications channel) — so a decoded call readsfi = [0, 1, …, 1, 2];dt = 2(DN / V/D mode 2),fn = j % 8(frame number, wrapping the 3-bit field),ft = 7,sq = sq.
build_frame assembles SYNC_BITS + build_fich(**fich) + payload, the payload being the five interleaved DCH/VCH pairs. The bit stream is bracketed by 24 zero pad bits each side, converted to dibits and modulated through demod.modulate.
6. Constants and tables
All values are quoted verbatim from the source module.
6.1 Frame sync and geometry (ysf.py)
SYNC = 0xD471C9634D # 40-bit frame sync, MSB-first
SYNC_BITS = [(SYNC >> (39 - i)) & 1 for i in range(40)]
FRAME_BITS = 960 # 40 sync + 200 FICH + 720 payload
As bits, 0xD471C9634D = 1101 0100 0111 0001 1100 1001 0110 0011 0100 1101.
6.2 FICH info fields (ysf.py)
_FICH_FIELDS = (("fi", 2), ("cs", 2), ("cm", 2), ("bn", 2), ("bt", 2),
("fn", 3), ("ft", 3), ("dt", 2), ("sql", 1), ("sq", 7))
# = 26 bits, zero-padded to 32 info bits before CRC
The published DN-mode reading of the fields (names as in the code):
| field | bits | meaning |
|---|---|---|
fi |
2 | Frame Information — 0 header, 1 communications channel, 2 terminator, 3 test |
cs |
2 | Call-Sign path / data-completeness indicator |
cm |
2 | Call Mode (group / individual addressing; 0 = group CQ) |
bn |
2 | Block Number (position of this CSD block in its sequence) |
bt |
2 | Block Total (number of CSD blocks) |
fn |
3 | Frame Number within the superframe |
ft |
3 | Frame Total |
dt |
2 | Data Type — 0 V/D mode 1, 1 Data FR, 2 V/D mode 2 (DN), 3 Voice FR |
sql |
1 | SQL (digital squelch / DG-ID) enable |
sq |
7 | SQL code (DG-ID / DSQ code) |
The 2/3-bit field layout is the published FICH structure; the exact per-field semantics beyond fi/dt are not exercised by the decoder (it round-trips the raw values). modulate_dn only sets fi, dt, fn, ft, sq.
6.3 K=5 convolutional code and Viterbi (ysf.py)
_G0, _G1 = 0b10011, 0b11101 # = 0o23, 0o35 (octal)
Rate-1/2, constraint length K = 5 (16 states, a 5-bit shift register). conv_encode shifts each input bit into a 5-bit register reg = ((reg<<1)|b) & 0x1F and emits [parity(reg & G0), parity(reg & G1)]. Encoding is trellis- terminated: 4 tail zero bits are appended so the register flushes to state 0.
viterbi(bits) is a hard-decision 16-state Viterbi expecting that termination. Path metrics start [0, ∞×15] (known start state 0); for each received dibit (r0, r1) and each (state, input-bit) it adds the branch penalty and keeps the best predecessor per next-state ns = reg & 0xF, then traces back from the minimum-metric final state. A received value ≥ 2 is treated as an erasure (no branch penalty), which is unused by YSF's full-rate FICH/DCH inputs but is the hook the punctured NXDN codes exploit through the same decoder.
6.4 Interleave and PN whitening (ysf.py)
_interleave(bits, rows, cols): write row-major into a rows×cols matrix,
read column-major → out[c*rows + r]
_deinterleave(bits, rows, cols): the exact inverse
FICH uses 20×10 (200 bits); DCH uses 20×18 (360 bits).
_prbs(n, seed=0x7F): 7-bit LFSR, feedback x^7 + x^4 + 1
reg = seed & 0x7F
for each output: out = (reg>>6)&1
fb = ((reg>>6) ^ (reg>>3)) & 1
reg = ((reg<<1) | fb) & 0x7F
_whiten(bits) = bits XOR _prbs(len(bits)) # involutive
_whiten is its own inverse (XOR with the same sequence), so the same call whitens on TX and de-whitens on RX.
6.5 CRC-16 (ysf.py)
CRC-16-CCITT: poly 0x1021, init 0xFFFF, MSB-first, no final XOR
_crc16(bits): r = 0xFFFF
for b in bits: r ^= (b & 1) << 15
r = ((r<<1) ^ 0x1021 if r & 0x8000 else r<<1) & 0xFFFF
Used for both the FICH info CRC (over 32 bits) and the DCH/CSD CRC (over 160 bits). Identical parameters to the P25 TSBK CRC (P25 §5.4).
6.6 Golay(24,12) — from P25 (p25/fec.py)
FICH uses the P25 perfect Golay(24,12,8) codec verbatim: generator polynomial _G23 = 0xC75 (x¹¹+x¹⁰+x⁶+x⁵+x⁴+x²+1), a full 2048-entry syndrome→error table for weight ≤ 3, extended to [24,12] by an overall even-parity bit. golay24_encode / golay24_decode correct up to 3 errors per codeword. See P25 §6.4 for the construction. The AMBE+2 channel frame reuses the same golay24/golay23 codecs (§5.4).
6.7 AMBE+2 half-rate channel frame — from ambe.py
HR_BITS = 49, CHANNEL_BITS = 72
hr_to_channel(bits49): Golay(24,12) over c0 (12 bits)
| PN-scrambled Golay(23,12) over c1 (12 bits)
| 25 unprotected bits
channel_to_hr(bits72) -> (49-bit parameter frame, corrected_bit_count) | (None, -1)
The PN scramble is a 15-bit LFSR seeded from the corrected c0 (ambe._pn) — the same PN family as P25's IMBE voice FEC. Full detail in AMBE.
7. Interoperability and validation
7.1 The test suite (test_ysf.py)
Six tests exercise every native layer; all use fixed RNG seeds and are self-consistent (encode → corrupt → decode):
| test | what it proves | key assertions |
|---|---|---|
test_fich_roundtrip_with_errors |
FICH chain corrects spread channel errors | build_fich = 200 bits; 4 bit-flips at 7/60/111/170 still recover fi=1, fn=3, ft=7, dt=2, sq=42 |
test_fich_crc_rejects_corruption |
FICH CRC rejects heavy corruption | 60 random bit-flips over the 200 → parse_fich returns None |
test_dch_csd_roundtrip |
DCH/CSD chain + whitening | build_dch = 360 bits; 4 flips still recover the 20-char CSD with crc_ok; _whiten(_whiten(x)) == x (involutive) |
test_single_frame_over_c4fm |
one full frame through the modem | build_frame = 960 bits; modulate → decode yields 1 frame with fn=2, csd_ok, CSD starts "CQ", and the 5 AMBE frames match exactly |
test_dn_call_end_to_end |
multi-frame DN call | 15 AMBE frames → 3 YSF frames; decoded fi = [0,1,2] (header/comm/terminator), fn = [0,1,2], all voice frames recovered, all csd_ok with CSD "ALL M0ABC " |
test_dn_call_through_noise |
DN call at 30 dB SNR | AWGN to 30 dB; all 3 frames decode with csd_ok; ≥ 13/15 voice frames recovered bit-exact |
The noise test is the strongest evidence the chain is sound end-to-end: it adds AWGN scaled to 30 dB SNR (n · sqrt(mean(audio²)) / 10**1.5) and still recovers all three FICH+CSD blocks and nearly all voice frames — the FICH/DCH FEC (Golay + K=5 convolution) and the AMBE channel-frame Golay carry the day.
7.2 On-air reference
The self-consistency tests validate the transcription against itself: the frame geometry, sync word, FICH field layout and the CRC → Golay → convolution → interleave coding chains are the published YSF structure and round-trip exactly. The final interoperability step — decoding a real off-air System Fusion signal — is not claimed here (see §8): the exact interleave orderings, the whitening seed/polynomial, the K=5 generator polynomials and YSF's precise V/D2 voice-bit protection are the module's on-air-interop refinements, not verified against captured Yaesu frames. The C4FM modem itself (p25/demod.py) is validated against P25 off-air captures via the P25 path.
8. Limitations
Stated honestly, roughly in order of impact. The module docstring draws the line precisely: "Spec-accurate: frame geometry, sync word, FICH field layout and the coding chains are the published structure. The interleave orderings, whitening seed, K=5 polynomials and YSF's exact V/D2 voice-bit protection (here the ambe.py half-rate channel frame) are the on-air interop refinements."
- Not bit-exact against real YSF. The interleave orderings (20×10 FICH, 20×18 DCH), the whitening seed/polynomial (0x7F, x⁷+x⁴+1), the K=5 generator polynomials (0o23/0o35) and the VCH voice-bit protection (the
ambe.py72-bit half-rate channel frame) are plausible, self-consistent reconstructions — not confirmed against captured on-air Yaesu frames. A real System Fusion radio would not necessarily interoperate with this receiver until these are matched to the published/observed values. - AMBE audio is native but not yet bit-exact. The voice path is fully in-repo (AMBE codec + MBE vocoder), but DVSI's exact AMBE+2 VQ tables, PN seed constant and inter-frame prediction are still to be transcribed for bit-exact on-air audio — the same stance as the IMBE spec. Recovered speech is intelligible-but-approximate until then.
- DN mode only. Only V/D type 2 (DN) is implemented. Voice-FR ("Wide"/VW), Data-FR and V/D mode 1 are C4FM but not decoded; the full-rate voice payload layout is not handled.
- FICH semantics are shallow. The decoder round-trips the ten FICH fields as raw values but only
fianddtdrive behaviour; block-sequence (bn/bt), call-mode (cm) and DG-ID/squelch (sql/sq) are surfaced, not acted on. - Block-at-a-time timing, no tracking. Sync and symbol timing are one-shot searches over the buffer (inherited from
p25/demod.py), with no per-symbol tracking loop — fine for clean captures, weaker on drifting or very weak signals. - No YSF network / Wires-X layer. This is the RF frame decoder only; the Wires-X internet-linking layer, DG-ID group management and reflector routing are out of scope.
None of these change the architecture — they are the natural next increments on a working native receive chain.
9. References
- Yaesu System Fusion / C4FM Digital — Yaesu's amateur digital-voice system (C4FM 4-level FSK, 4800 sym/s, DN / VW modes, AMS). The frame geometry, 40-bit sync, FICH field layout and the CRC → Golay → convolution → interleave coding chains follow the published/community-documented YSF structure (cited
ysf.py). - AMBE+2 half-rate (TIA-102.BABA family) — the 49-bit → 72-bit voice channel frame shared with DMR and NXDN (EHR); implemented natively in
ambe.py→ the MBE vocoder. - P25 Phase 1 (TIA-102.BAAA) — the C4FM modem (
p25/demod.py) and the Golay(24,12,8)/(23,12,7) codec (p25/fec.py) reused unchanged; see P25. - In-repo implementation:
app/radio/ysf.py; the reused modem/FECapp/radio/p25/demod.py,app/radio/p25/fec.py; the voice codecapp/radio/ambe.py; tests intest_ysf.py. - Cross-links: AMBE (the half-rate voice channel frame), P25 (shared C4FM modem + Golay), MBE vocoder (AMBE parameters → PCM), NXDN (reuses this K=5 convolution + Viterbi with punctured/erasure inputs).