Patrick Lidstone
Self-hosted

POCSAG: native CCIR Radiopaging Code No. 1 decoder — 2-FSK at 512/1200/2400 baud

A self-contained, clean-room POCSAG pager decoder (and a matching encoder for self-test): 2-level FSK carried as FM-discriminator NRZ baseband, sliced to hard bits, framed by the 0x7CD215D8 sync codeword into batches of 8 frames × 2 codewords, each a (31,21) BCH-protected + even-parity 32-bit codeword — decoded to address (RIC + function) and numeric-BCD / 7-bit-ASCII message payloads.

Rafe project · app/radio/pocsag.py · reproduction spec (benchmark: ../rvqvoice.md)


Abstract

POCSAG (the CCIR Radiopaging Code No. 1, standardised as ITU-R M.584) is the 1980s pager protocol still carrying VHF/UHF paging traffic worldwide. Rafe decodes it natively — pure Python + NumPy, no external process — in a single ~290-line module, app/radio/pocsag.py, a drop-in replacement for multimon-ng -a POCSAG512 -a POCSAG1200 -a POCSAG2400 (pocsag.py). POCSAG is direct 2-level FSK; on an FM receiver the discriminator audio is the NRZ baseband, so the decoder takes that audio, removes the slow DC droop, resamples to 8 samples per bit, and hard-slices at zero to recover the bit stream. It then hunts a 32-bit rolling window for the frame-synchronisation codeword 0x7CD215D8 (in either polarity), reads the following batch of 16 codewords (8 frames × 2), and decodes each 32-bit codeword: strip the trailing even-parity bit, run a BCH(31,21) syndrome decode correcting up to two bit errors, then split the 21 information bits into a 1-bit flag (0 = address, 1 = message) and 20 data bits. Address codewords give the receiver's capcode (RIC: 18 bits + the 3-bit frame index + 2 function bits); message codewords concatenate their 20-bit fields into a payload read either as reversed-BCD numeric (4 bits/char) or as 7-bit ASCII, LSB-first (alphanumeric). The module ships its own encoder (modulate) that renders conforming NRZ FM audio, and an AWGN self-test recovers pages down to ~20 dB SNR at all three baud rates. The output line is formatted to mirror multimon-ng's exactly (digimodes.py) so the UI and any downstream tooling are unchanged.

The document below is written so that a DSP-literate reader can reimplement the decoder from scratch, bit-for-bit, without reading the source. Every constant is quoted with its source file; the frame-sync and idle codewords, the BCH generator, the numeric charset, and the address/function bit layout are all tabulated.


1. Background

1.1 POCSAG paging

POCSAG — the Post Office Code Standardisation Advisory Group code, adopted by the CCIR as Radiopaging Code No. 1 and published as ITU-R M.584 — is a one-way (transmit-only, base → pager) digital paging protocol. A paging transmitter continuously radiates a synchronous bit stream; each pager holds a factory-programmed identity (a RIC, Radio Identity Code, a.k.a. capcode) and wakes only when it hears a codeword addressed to it. Messages are short: a tone alert (address only, no body), a numeric body (digits and a few symbols), or an alphanumeric body (ASCII text). POCSAG is battery-frugal by design — see the frame-slotting scheme in §1.4 — which is why it out-lived fancier successors for utility, medical and emergency paging.

1.2 Two-level FSK and the NRZ baseband

POCSAG modulation is direct binary FSK: the RF carrier is shifted between two frequencies, nominally ±4.5 kHz deviation, one for a binary 1 and the other for a binary 0, with no pulse shaping — the baseband is plain NRZ (non-return-to-zero: the level is held constant for the whole bit period). On any FM receiver the discriminator output (the demodulated instantaneous frequency) is therefore a two-level NRZ waveform: a positive level for one symbol, a negative level for the other. Rafe exploits exactly this — it never touches RF or does FSK tone discrimination itself; it treats the incoming audio as the discriminator baseband and recovers bits by sign-slicing it (pocsag.py). Polarity (which frequency means 1) is not fixed across radios, so the decoder is made polarity-agnostic by also matching the inverted sync word (§4.3).

Three symbol rates are in use and all three are tried at once: 512, 1200 and 2400 baud (pocsag.py). 512 baud is the original POCSAG rate; 1200 and 2400 are the faster variants (sometimes marketed as "Super-POCSAG").

1.3 Codewords, frames, batches — the container hierarchy

Every unit of POCSAG framing is a 32-bit codeword. There are three kinds:

  • Frame-synchronisation codeword (FSC) — the fixed pattern 0x7CD215D8 (pocsag.py) that marks the start of a batch.
  • Address codeword — flag bit 0; carries a truncated RIC and 2 function bits.
  • Message codeword — flag bit 1; carries 20 bits of message payload.
  • Idle codeword — the fixed pattern 0x7A89C197 (pocsag.py), a specific (valid) address codeword used as filler when a frame slot has nothing to send.

Codewords are grouped into frames (2 codewords each) and frames into batches (8 frames). A batch on the wire is:

[ FSC 0x7CD215D8 ] [ frame0: cw cw ][ frame1: cw cw ] … [ frame7: cw cw ]
      1 codeword           2 codewords × 8 frames = 16 codewords
      = 32 bits                        = 512 bits
─────────────────────────────────────────────────────────────────────────
            one batch = 1 + 16 = 17 codewords = 544 bits

The module's constant BATCH_CODEWORDS = 16 (pocsag.py) counts the 16 address/message codewords after the sync; the sync is handled separately (§4.3). So "batch" = 1 FSC (32 bits) + 16 codewords (512 bits) = 17 codewords, 544 bits.

1.4 Per-address frame slotting (why the frame index matters)

A pager does not listen to the whole batch — it listens only to one of the 8 frames, chosen by its RIC: frame = RIC mod 8. The base station must place a pager's address codeword in that pager's frame. This is the battery-saving trick: the receiver can sleep through 7/8 of every batch and only power its decoder up for its assigned frame. The consequence for the bit layout is that the low 3 bits of the RIC are not transmitted inside the address codeword — they are implied by the frame position in which the address appears. The 18 bits carried in the codeword are RIC >> 3; the decoder reconstructs the full RIC by OR-ing the frame index back in (§5.4).

1.5 BCH-protected codewords

Paging channels are noisy and one-way (no ARQ), so each codeword is forward-error-protected. The 21 information bits (1 flag + 20 data) are extended to 31 bits by a (31,21) BCH code — a 10-bit cyclic redundancy computed from a degree-10 generator polynomial — giving a code of minimum distance 5 that corrects up to 2 bit errors per codeword. A final even-parity bit over the 31 makes the 32-bit codeword and lifts the distance to 6 (2-error-correct, 3-error-detect). Rafe implements the BCH syndrome decode with a precomputed single-error-syndrome table and an O(31²) two-error search (§5.2).


2. Signal structure (exact)

All values are from app/radio/pocsag.py unless noted.

Parameter Value Source
Modulation 2-level FSK, NRZ baseband (FM-discriminator audio) pocsag.py
RF deviation (real signal) nominally ±4.5 kHz ITU-R M.584 (not set in code)
Symbol rates 512 / 1200 / 2400 baud pocsag.py; bauds=(512,1200,2400) — pocsag.py
Bit = level 1 → positive, 0 → negative (TX convention) nrz = stream*2−1 — pocsag.py
RX polarity either — matches SYNC and ~SYNC pocsag.py
Preamble 576 bits of alternating 0101… (0x55-style reversals) PREAMBLE_BITS = 576 — pocsag.py; [0,1]*(576//2) — pocsag.py
Frame-sync codeword (FSC) 0x7CD215D8 SYNC — pocsag.py
Idle codeword 0x7A89C197 IDLE — pocsag.py
Codeword length 32 bits = 1 flag + 20 data + 10 BCH + 1 parity _codeword — pocsag.py
Frame 2 codewords §1.3
Batch 1 FSC + 8 frames × 2 = 17 codewords = 544 bits BATCH_CODEWORDS = 16 (data cws) — pocsag.py
Error control BCH(31,21), 2-bit correcting, + even parity _BCH_POLY = 0x769 — pocsag.py
Numeric charset 16 chars, 4 bits/char, LSB-first (reversed BCD) _NUMERIC — pocsag.py
Alpha charset 7-bit ASCII, LSB-first _alpha_to_bits — pocsag.py
RX sample rate 22050 Hz decode(..., sr=22050) — pocsag.py; PocsagDecoder(..., sr=22050) — digimodes.py
RX oversampling 8 samples/bit os = 8 — pocsag.py

2.1 The bit stream on the wire

   preamble               batch 0                        batch 1
 ┌──────────────┐┌────────────────────────────┐┌────────────────────── …
 │ 0101…0101    ││ FSC │ f0 │ f1 │ … │ f7      ││ FSC │ f0 │ …
 │ ≥576 bits    ││ 32b │2cw │2cw │   │2cw      ││ 32b │
 └──────────────┘└────────────────────────────┘└────────────────────── …
                    ▲       └ each frame = 2 codewords (32 bits each) ┘
                    └ 0x7CD215D8 marks every batch

The receiver locks on the long alternating preamble (a square wave at half the baud rate — the maximum-transition pattern, ideal for clock recovery), then finds the FSC to establish codeword/frame phase. Every batch is preceded by its own FSC, so the decoder re-synchronises each batch and can join mid-stream.

2.2 One 32-bit codeword

 bit  31        30 …………………………………………… 11        10 ……………………… 1        0
     ┌────┬──────────────────────────────┬─────────────────────┬────────┐
     │flag│         20 data bits          │   10 BCH check bits │ parity │
     └────┴──────────────────────────────┴─────────────────────┴────────┘
       │           (MSB … LSB)                                     even over
       │  0 = address codeword                                     bits 31..1
       └▶ 1 = message codeword

flag = bit 31 of the 32-bit word (= bit 30 of the 31-bit BCH word after the parity bit is stripped). The parity bit (bit 0) is even parity over the upper 31 bits — so the whole 32-bit codeword always has even weight (_codeword, pocsag.py; verified: both SYNC and IDLE have 32-bit popcount 16).

Address codeword (flag = 0), the 20 data bits split as:

 data20 =  [ 18-bit address (RIC>>3) ] [ 2-bit function ]
              bits 19..2                    bits 1..0

Full RIC = (address18 << 3) | frame_index, where frame_index (0–7) is the frame the codeword sits in (§1.4, §5.4). The 2 function bits select the alert type; Rafe maps function 0 → numeric, any other value → alphanumeric (§5.5).

Message codeword (flag = 1): all 20 data bits are payload, concatenated MSB-first across successive message codewords into one bit vector (§5.6).


3. The transmit / self-test encoder

Although the deliverable is a decoder, the module carries a complete encoder, modulate (pocsag.py), used by the tests and for self-validation. It is the clearest statement of the wire format, so it is documented first.

modulate(pages, sr=22050, baud=1200) takes a list of (ric, text, numeric?) tuples and returns s16le mono PCM — the synthetic NRZ FM-discriminator audio:

  1. Preamble — start the bit list with [0,1] * (PREAMBLE_BITS//2) = 576 alternating bits (pocsag.py).
  2. Per page (pocsag.py):
    • frame = ric & 7 — the pager's assigned frame (low 3 RIC bits).
    • addr18 = ric >> 3 — the 18 bits actually carried.
    • func = 3 if not numeric else 0function 0 for numeric, 3 for alpha.
    • Build the address codeword addr_cw = _codeword(0, (addr18<<2)|func) (flag 0; pocsag.py).
    • Turn the text into message bits: _numeric_to_bits or _alpha_to_bits (pocsag.py), pack them into 20-bit message codewords with flag 1 and 1-padding of the final short field (_pack_message_codewords, pocsag.py).
    • Fill a 16-slot batch with IDLE codewords, then place the address at batch[2*frame] and the message codewords in the following slots 2*frame+1, 2*frame+2, … (single-batch only — it stops at slot 16; pocsag.py).
    • Emit _bits_msb(SYNC, 32) then the 16 codewords, each _bits_msb(cw, 32) (MSB-first; pocsag.py).
  3. Trailing [0,1]*32 (pocsag.py).
  4. NRZ render (pocsag.py): hold each bit for sps = sr/baud samples (nearest-sample index map), map bit 1 → +1, 0 → −1 (nrz = stream*2−1), scale to 0.6 full-scale, and pack <i2.

So modulate is the exact inverse of the decoder and is what the round-trip tests exercise (§7).


4. Demodulation — audio to hard bits (step by step)

The demod front-end is inside decode(pcm, sr=22050, baud=1200) (pocsag.py). It is deliberately trivial DSP — POCSAG's NRZ baseband needs only DC removal, resampling and a sign-slice — with the cleverness in the framing search (§4.3) and the BCH decode (§5).

4.1 Float conversion and DC-droop removal

Read the buffer as little-endian int16 → float64 (pocsag.py). Return empty if fewer than 64 samples (pocsag.py).

The FM discriminator adds a slow DC offset and drift that would bias the zero-slice. It is removed by subtracting a moving-average (boxcar) of the signal — a crude high-pass:

win = max(64, int(sr / baud * 32))          # ~32 bit-periods
x = x - np.convolve(x, np.ones(win)/win, mode="same")     # pocsag.py

The window length is ~32 bit-periods, chosen deliberately (pocsag.py): a short window droops on long constant runs (a run of five 1s occurs inside the sync word), which would flip sliced bits; 32 bit-periods is long enough that realistic data runs survive while a slow FM DC offset is still cancelled.

4.2 Resampling to 8 samples/bit

The signal is linearly interpolated onto a grid of exactly os = 8 samples per bit (pocsag.py):

nbits     = int(x.size * baud / sr)
sample_at = np.arange(nbits*os) * (sr/baud/os)
r         = np.interp(sample_at, np.arange(x.size), x)

This decouples the framing search from the true sample rate: whatever sr/baud, downstream code sees 8 uniform sub-samples per bit.

4.3 Bit slicing across all 8 timing phases + sync acquisition

There is no explicit clock-recovery loop. Instead the decoder brute-forces the bit phase: for each of the 8 sub-bit offsets it takes every 8th sample, sign-slices it, and runs the full framing search (pocsag.py):

for phase in range(os):
    bits = (r[phase::os] > 0).astype(np.uint8)   # hard slice: >0 → 1, else 0
    _find_batches(bits, emit)

bits > 0 is the FSK slicer: positive baseband → 1, negative → 0 (matching the TX convention of §2). Trying all 8 phases guarantees one of them samples near the bit centres regardless of the incoming timing offset — cheap, and robust for short buffers.

Sync search (_find_batches, pocsag.py): a 32-bit value is built by shifting in one hard bit at a time (word = ((word<<1)|bit) & 0xFFFFFFFF). Once 32 bits are resident, word is compared against both the frame-sync codeword and its bitwise inversion:

if word == SYNC or word == (~SYNC & 0xFFFFFFFF):
    inv = (word != SYNC)                 # matched the inverted pattern → RX is inverted
    seen += _decode_batches_from(bits, i, inv, on_msg)

Matching ~SYNC is what makes the decoder polarity-agnostic: if the radio's discriminator inverts the baseband, every bit (and thus the whole sync word) is flipped, and the inv flag is threaded through so every subsequent codeword is un-inverted by XOR (§5.1). This is an exact 32-bit match — no bit-error tolerance on the sync word itself (the FSC is chosen for good autocorrelation, so a false match is improbable).


5. Codeword decoding (step by step)

Once a sync is found at bit offset pos, _decode_batches_from (pocsag.py) walks consecutive batches.

5.1 Assembling a 32-bit codeword

For codeword c (0–15) in the batch, the 32 bits at pos + c*32 + k are packed MSB-first into an integer, each bit XORed with the polarity flag (pocsag.py):

raw = 0
for k in range(32):
    b = int(bits[pos + c*32 + k]) ^ (1 if inv else 0)
    raw = (raw << 1) | b

5.2 BCH(31,21) syndrome decode + correction (_correct)

_correct(cw32) (pocsag.py) strips the parity bit and BCH-decodes the 31-bit word:

word31 = cw32 >> 1                 # drop the even-parity LSB (parity is NOT checked)
s = _syndrome(word31)

The syndrome is the remainder of dividing the 31-bit word by the generator polynomial _BCH_POLY = 0x769, computed by the same shift-XOR long-division used for encoding (_syndrome, pocsag.py):

def _syndrome(word31):
    val = word31
    for i in range(30, 9, -1):
        if (val >> i) & 1:
            val ^= _BCH_POLY << (i - 10)
    return val & 0x3FF             # 10-bit remainder

A zero syndrome means a valid codeword. A non-zero syndrome triggers correction against a precomputed table of the 31 single-bit-error syndromes (_SYND1 = [_syndrome(1<<i) for i in range(31)], pocsag.py):

  • 1-bit error: find i with _SYND1[i] == s; flip bit i (pocsag.py).
  • 2-bit error: find i, j with _SYND1[i] ^ _SYND1[j] == s (linearity of the syndrome) and flip both (pocsag.py). This is an O(31²) double loop.
  • Uncorrectable (3+ errors, no match): return None — the codeword is dropped (pocsag.py).

On success it returns (flag, data20) extracted from the (corrected) 31-bit word (pocsag.py):

return ((word31 >> 30) & 1, (word31 >> 10) & 0xFFFFF)

Note: the even-parity bit is stripped and never checked — correction relies entirely on the BCH syndrome. So the decoder realises the BCH code's 2-error correction but not the extra error-detection the parity bit would add (§8).

The encoder side is the mirror: _bch_parity (pocsag.py) is the identical division applied to info21 << 10 to produce the 10 check bits, and _codeword appends even parity (bin(word31).count("1") & 1, pocsag.py).

5.3 The batch loop and re-sync

Within a batch the loop keeps a current address context (pocsag.py):

cur_ric = None; cur_numeric = False; payload = []
for c in range(BATCH_CODEWORDS):        # 16 codewords
    dec = _correct(raw)
    if dec is None: continue            # drop uncorrectable codewords
    flag, data20 = dec
    if flag == 0:   … address …
    elif flag == 1 and cur_ric is not None:   payload += _bits_msb(data20, 20)

After the 16 codewords, any accumulated payload is emitted (pocsag.py). The decoder then advances pos by 16*32 bits and requires the next 32 bits to be the sync word again (un-inverted per inv); if they are, pos += 32 and it decodes the next batch, otherwise it stops (pocsag.py). This chains contiguous batches while rejecting stray sync matches.

5.4 Address codewords → RIC + function

When a flag-0 codeword arrives (pocsag.py):

if payload and cur_ric is not None:            # a previous message just ended
    on_msg(_message(cur_ric, cur_numeric, payload)); payload = []
func     = data20 & 3
cur_ric  = ((data20 >> 2) << 3) | (c // 2)     # 18-bit addr, low 3 bits = frame index
cur_numeric = (func == 0)

Key points:

  • A new address flushes any message accumulated for the previous address (multiple pages can share one batch).
  • The full RIC is reconstructed as (address18 << 3) | (c // 2). Since a frame is 2 codewords, c // 2 is the frame index 0–7 — exactly the low 3 RIC bits that frame-slotting omitted from the codeword (§1.4). (In the encoder the address sits at batch[2*frame], so c = 2*frame and c//2 = frame.)
  • The 2 function bits are data20 & 3; func == 0 selects numeric decoding, any other value selects alphanumeric (§5.5).

5.5 Function bits → numeric vs alphanumeric

POCSAG's function bits nominally select one of four alert types, and whether a body is numeric or alphanumeric is strictly a pager-configuration matter, not something the standard encodes. Rafe adopts a simple, self-consistent heuristic: func == 0 ⇒ numeric, otherwise alphanumeric (decoder pocsag.py; encoder emits func = 0 for numeric and func = 3 for alpha, pocsag.py). This round-trips perfectly with its own encoder and matches the common real-world convention; it is a documented simplification versus multimon-ng, which can render both interpretations (§8).

5.6 Message codewords → payload bits

Each flag-1 codeword contributes its 20 data bits, MSB-first, to the running payload list (payload += _bits_msb(data20, 20), pocsag.py). _bits_msb(value, n) (pocsag.py) expands an integer to a bit list, most significant first. Payload is therefore the codewords' 20-bit fields concatenated in order — a single flat bit vector spanning as many message codewords as the page used.

5.7 Payload bits → text (_decode_payload)

_decode_payload(bits, numeric) (pocsag.py) slices the flat bit vector into characters, each read LSB-first:

  • Numeric (4 bits/char, pocsag.py):
    for i in range(0, len(bits)-3, 4):
        code = sum(bits[i+k] << k for k in range(4))     # LSB-first ⇒ reversed BCD
        out.append(_NUMERIC[code])
    The 16-entry table _NUMERIC = "0123456789*U -)(" (pocsag.py) maps the 4-bit code to a character (see §6.4).
  • Alphanumeric (7 bits/char, pocsag.py):
    for i in range(0, len(bits)-6, 7):
        c = sum(bits[i+k] << k for k in range(7))        # 7-bit ASCII, LSB-first
        if 0x20 <= c < 0x7F or c in (0x0A, 0x0D):        # printable + CR/LF only
            out.append(chr(c))

Finally trailing padding is stripped: numeric text is rstrip("( ") (the pad characters), alpha text is rstrip() (pocsag.py). The result is wrapped as {"ric", "mode": "numeric"|"alpha", "text"} by _message (pocsag.py).

The LSB-first bit order (both the 4-bit numeric codes and the 7-bit ASCII) is the POCSAG standard: each character's bits are transmitted least-significant first.


6. Constants and tables (quick reference, all quoted)

6.1 Module constants (pocsag.py)

SYNC            = 0x7CD215D8      # frame-sync codeword (FSC)
IDLE            = 0x7A89C197      # idle / filler codeword
_BCH_POLY       = 0x769           # BCH(31,21) generator
PREAMBLE_BITS   = 576             # ≥576-bit alternating preamble
BATCH_CODEWORDS = 16              # 8 frames × 2 codewords (excl. the FSC)
_NUMERIC        = "0123456789*U -)("

6.2 The BCH(31,21) generator polynomial

_BCH_POLY = 0x769 = binary 111 0110 1001 — bits set at positions {10, 9, 8, 6, 5, 3, 0} (verified), i.e.

g(x) = x¹⁰ + x⁹ + x⁸ + x⁶ + x⁵ + x³ + 1

exactly as the in-source comment states (pocsag.py). This is the standard POCSAG BCH(31,21) generator: a degree-10 polynomial over GF(2) giving a cyclic code of length 31, 21 information bits, minimum distance 5 → corrects 2 bit errors. With the appended even-parity bit the effective (32,21) code has distance 6.

6.3 Sync and idle codewords (both are valid, even-parity BCH codewords)

Both special codewords satisfy the BCH check (syndrome 0) and even parity — verified by long division:

Codeword Hex flag data20 func syndrome 32-bit weight
FSC (frame sync) 0x7CD215D8 0 0xF9A42 2 0 (valid) 16 (even)
IDLE 0x7A89C197 0 0xF5138 0 0 (valid) 16 (even)

The FSC is matched before BCH decoding (as a raw 32-bit pattern, §4.3), so its "address" interpretation is never used. IDLE decodes as a flag-0 address with func=0; because idle frames carry no message codewords, no payload accumulates and IDLE emits nothing (the if payload … guards, pocsag.py) — it is harmless filler.

6.4 The numeric character set (_NUMERIC, pocsag.py)

16 entries indexed by the 4-bit code (read LSB-first from the wire):

Code 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Char 0 1 2 3 4 5 6 7 8 9 * U (space) - ) (

This is the conventional POCSAG numeric-pager alphabet (the same 16 glyphs multimon-ng prints): digits 09 plus * U ␣ - ) ( for codes 10–15.

6.5 Address / function bit layout (address codeword, flag = 0)

 32-bit codeword:  [ flag=0 | data20 (20b) | BCH (10b) | parity (1b) ]
 data20 (20b):     [ address18 (bits 19..2) | function (bits 1..0) ]
 full RIC (21b):   [ address18 | frame_index (3b, = batch frame 0..7) ]
                     ── carried ──   ── implied by frame position ──
  • address18 = data20 >> 2 — the transmitted RIC bits (RIC >> 3).
  • function = data20 & 3 — alert type; 0 ⇒ numeric, else alphanumeric.
  • frame_index = the frame the codeword occupies (c // 2 on RX, RIC & 7 on TX) — the low 3 RIC bits, not transmitted (§1.4, §5.4).

6.6 Message payload codecs

Mode Bits/char Order Table Source
numeric 4 LSB-first (reversed BCD) _NUMERIC (§6.4) pocsag.py
alphanumeric 7 LSB-first, 7-bit ASCII chr(c), printable + CR/LF pocsag.py

7. Interoperability and validation

7.1 Output identical to multimon-ng

The native decoder is a drop-in for multimon-ng -a POCSAG512 -a POCSAG1200 -a POCSAG2400 (pocsag.py). The app formats each decoded page to mirror multimon-ng's line exactly (_fmt_pocsag, digimodes.py):

POCSAG1200: Address: 1234560  Alpha: CQ DE M0SUP

kind is "Numeric" or "Alpha" from the page's mode, Address is the RIC, and the baud is appended to POCSAG. So the UI and any log scraper see the same text whether the native or the multimon backend is selected — the backend is chosen by the POCSAG_BACKEND environment variable, default "native" (digimodes.py); setting it to "multimon" restores the external pipe (digimodes.py).

Because POCSAG is a published standard (ITU-R M.584) and the module implements the standard framing (0x7CD215D8 sync, (31,21) BCH, the numeric/alpha charsets), a bit stream produced by modulate is a conforming POCSAG signal that multimon-ng decodes, and any conforming POCSAG transmitter (or multimon-ng's own decode of off-air paging) produces the addresses/messages this decoder reads.

7.2 Self-test suite

test_pocsag.py exercises the module end-to-end:

Test What it checks
test_bch_correction (test_pocsag.py) _codeword(1, 0xABCDE) round-trips; a 2-bit error (bits 3 & 19) is corrected; a 3-bit error is not mis-corrected.
test_roundtrip_alpha_and_numeric (test_pocsag.py) modulate two pages — an alpha ("CQ DE M0SUP", RIC 1234560) and a numeric ("01234567", RIC 1000008) — then decode; both (ric, text) pairs are recovered.
test_all_bauds (test_pocsag.py) full modulate→decode at 512, 1200, 2400 baud.
test_survives_awgn (test_pocsag.py) a page under +20 dB SNR additive white Gaussian noise still decodes ("NOISY PAGE 42").
test_streaming_multibaud (test_pocsag.py) the streaming PocsagDecoder (trying 512/1200/2400 at once) recovers a page fed in 4000-byte chunks, tagged with the correct baud.

The AWGN case is the meaningful one: it proves the BCH correction plus the all-phases sync search recover pages through real channel noise, not just in a clean loopback. Run with python3 -m pytest test_pocsag.py.

7.3 App integration (RX path)

  • Mode/rig setup: selecting POCSAG puts the rig in FM with the wide filter (manager.py — fm = new in ("pocsag","aprs","m17","p25")), because POCSAG is FM/FSK, not SSB.
  • Audio feed: the 48 kHz RX audio is linearly resampled to 22050 Hz and handed to PocsagDecoder.feed (_feed_pocsag, digimodes.py).
  • Streaming decoder (PocsagDecoder, pocsag.py): buffers audio, re-decodes once ~0.5 s of new audio has arrived, tries all three baud rates, de-duplicates on (ric, text), and bounds both the audio buffer (~3 s) and the dedup set (pocsag.py). It calls on_msg({ric, mode, text, baud}) per new page.
  • Quick-tune chips default to UK VHF paging: 153025/153125/153350 kHz FM (digimodes.py).

8. Limitations

Honest gaps in the current implementation:

  • Exact sync match, no bit tolerance. The 32-bit FSC must match SYNC or ~SYNC bit-for-bit (pocsag.py). A sync word with even one sliced-bit error is missed and that whole batch is lost. multimon-ng permits a couple of bit errors in the sync correlation; adding a Hamming-distance threshold here would recover more batches in deep noise.
  • Parity bit unused. _correct strips the even-parity bit and never checks it (pocsag.py); only the BCH syndrome is used. The code therefore gets 2-bit correction but forgoes the extra error-detection the (32,21) distance-6 parity would give — a mis-correction that the parity would have flagged can slip through as a wrong-but-plausible codeword.
  • Numeric/alpha decided by func == 0. Whether a body is numeric or alphanumeric is a heuristic on the function bits (§5.5), not a property POCSAG actually transmits. A real network that uses function 0 for an alpha pager (or vice-versa) would be decoded in the wrong charset. multimon-ng sidesteps this by offering both renderings.
  • Single-batch messages in the encoder. modulate fills exactly one 16-slot batch per page and stops if a long message overruns the batch (pocsag.py, comment "single-batch messages here"). Long real-world messages spanning multiple batches encode truncated. The decoder has no such limit — it concatenates message codewords across as many batches as follow — but the self-test encoder cannot generate them, so multi-batch messages are untested from this module's own TX.
  • Timing by brute-force phase search, not tracking. All 8 sub-bit phases are tried per buffer (pocsag.py) rather than a tracking clock-recovery loop. This is simple and robust for short buffers but does no timing interpolation — with only 8 phases and a sample-rate/baud ratio that is not an exact multiple, the effective bit centre can sit up to 1/16 bit off. Fine at the tested SNRs; a Gardner or Mueller–Müller TED would tighten it at low SNR.
  • No FSK discrimination / AFC. The decoder assumes the input is already FM-discriminator NRZ baseband (§1.2). It does not demodulate FSK from an IF/audio tone pair and has no automatic frequency control; the signal must arrive as clean discriminator audio (which is exactly what the FM-mode rig path provides).
  • DC-droop high-pass is a boxcar. The ~32-bit-period moving-average subtraction (pocsag.py) is a blunt high-pass; a pathological very-long constant run (longer than realistic POCSAG framing produces) could still droop across the slicer. The window length is a tuned compromise, documented in-source.

None of these are architectural; each is a bounded addition on a working, standards-conforming base.


9. Implementation and reproduction

File Role
app/radio/pocsag.py the whole decoder + self-test encoder: BCH(31,21), framing, charsets, decode, PocsagDecoder, modulate
test_pocsag.py BCH, numeric/alpha round-trip, all bauds, AWGN, streaming (§7.2)
app/radio/digimodes.py app wiring — native/multimon backend select, _fmt_pocsag, 48 k→22050 resample + feed (_feed_pocsag), quick-tune
app/radio/manager.py rig control — FM+wide filter for POCSAG

Runtime API:

from app.radio import pocsag

# --- decode a whole buffer (one baud) ---
pages = pocsag.decode(pcm_s16le, sr=22050, baud=1200)
#   -> [{"ric": 1234560, "mode": "alpha", "text": "CQ DE M0SUP", "baud": 1200}, …]

# --- streaming, all three bauds at once (the app path) ---
dec = pocsag.PocsagDecoder(on_msg=print, sr=22050)   # tries 512/1200/2400
dec.feed(pcm_s16le_chunk)                             # call repeatedly

# --- self-test encoder (synthetic NRZ FM audio) ---
audio = pocsag.modulate([(1234560, "CQ DE M0SUP", False)], sr=22050, baud=1200)

Everything at runtime is NumPy; there is no external process, no data table beyond the constants quoted in §6, and no state carried between buffers except the streaming decoder's audio buffer and dedup set.


10. References

  1. ITU-R Recommendation M.584Codes and formats for radio paging (formalises the CCIR Radiopaging Code No. 1 / POCSAG framing, the frame-sync codeword, the (31,21) BCH code, and the numeric/alphanumeric message formats).
  2. CCIR Radiopaging Code No. 1 (POCSAG) — the Post Office Code Standardisation Advisory Group specification: 512/1200/2400 baud 2-FSK, 576-bit preamble, batches of 8 frames × 2 codewords, per-address frame slotting.
  3. R. C. Bose, D. K. Ray-Chaudhuri, and A. Hocquenghem — the BCH cyclic error-correcting codes; the (31,21) 2-error-correcting code with generator x¹⁰+x⁹+x⁸+x⁶+x⁵+x³+1 used per codeword (§6.2).
  4. multimon-ng (Thomas Sailer, Elias Oenal, and contributors) — the reference open-source POCSAG decoder whose POCSAG512/1200/2400 output this module replaces and whose line format it mirrors (§7.1).
  5. ../rvqvoice.md — the depth/structure benchmark for this reproduction-spec series; rtty.md — the sibling native-mode spec.