Patrick Lidstone
Self-hosted

PSK31 / PSK63 / PSK125: native differential-BPSK keyboard mode

A self-contained, dependency-free differential-BPSK modem with raised-cosine symbol shaping and the G3PLX varicode — on-air compatible with fldigi/DM780 PSK31, plus its 2× and 4× rate cousins.

Rafe project · app/radio/psk.py, app/radio/pskvaricode.py · in-repo, pure Python (no NumPy at runtime)


Abstract

PSK31 is the workhorse HF keyboard mode: differential binary phase-shift keying (DBPSK) at 31.25 baud on a single audio subcarrier, carrying text as a prefix-free varicode whose codewords are delimited by the two-bit sequence 00. A 180° carrier phase reversal encodes a 0 bit; keeping the phase encodes a 1. The amplitude is shaped by a raised-cosine envelope that dips to zero at every phase reversal, which is what confines a ~31 Hz-baud signal to a ~31 Hz occupied bandwidth and gives PSK31 its famously narrow, splatter-free spectrum. PSK63 and PSK125 are the identical waveform at 2× and 4× the symbol rate.

This document specifies Rafe's native implementation in full. The modulator (modulate, app/radio/psk.py) generates transmit audio directly as signed 16-bit PCM; the demodulator (PskDecoder, app/radio/psk.py) does a complex downconversion to baseband, a single-pole low-pass, decimation to ~16 samples per symbol, boxcar integrate-and-dump per symbol, differential detection by the sign of the symbol-to-symbol dot product, and varicode framing on the 00 separator. No carrier PLL and no Costas loop are used — differential detection tolerates a static carrier offset up to roughly ±baud/4, so the receiver simply mixes at a fixed 1000 Hz tone. The varicode table (app/radio/pskvaricode.py) is transcribed from fldigi (W1HKJ, GPL-3); the underlying varicode is Peter Martinez G3PLX's published PSK31 standard. A reader can reimplement the mode bit-for-bit from this document alone.


1. Motivation

PSK31 is the single most-used HF digital text mode, and every reference decoder for it (fldigi, DM780, MixW) is an external binary. Rafe has moved the digital text modes in-house — native FT8, the WSJT-X family, and the fldigi family — for the usual reasons: no build dependencies, full control over the DSP, and something that is genuinely part of the project rather than a subprocess. PSK31/63/125 is a natural member of that family: the waveform is simple enough to implement in a few dozen lines of pure Python, the varicode is a published public standard, and the result interoperates on-air with the rest of the world's PSK31 stations.

The design goal here is interoperability plus minimalism: match the on-air waveform closely enough to work against real fldigi signals, while keeping the code small, allocation-light, and free of NumPy at runtime (it uses only cmath, math, and struct, per app/radio/psk.py).


2. Background

2.1 BPSK and differential BPSK

Binary phase-shift keying carries one bit per symbol in the phase of a carrier: phase 0 for one bit value, phase π (a 180° reversal) for the other. Coherent BPSK demodulation needs to know the absolute carrier phase, which requires a phase-locked loop (a Costas loop for a suppressed-carrier signal) and suffers a 180° phase ambiguity that must be resolved separately.

Differential BPSK sidesteps both problems. Instead of encoding the bit in the absolute phase, it encodes the bit in the change of phase between consecutive symbols: a reversal (π step) versus no change. The receiver need only compare each symbol against the one before it, so it never has to acquire an absolute phase reference and there is no ambiguity to resolve. The price is a modest noise penalty (a detection error in symbol n tends to corrupt the decision at both n and n+1) — an excellent trade for HF, where carrier phase drifts constantly.

PSK31's convention (app/radio/psk.py): a 180° phase reversal encodes bit 0; no reversal encodes bit 1. Idle (no data) is a continuous stream of 0 bits, i.e. a continuous string of phase reversals — this is the acquisition preamble and the on-air "PSK31 idle" tone.

2.2 Varicode

Morse code is efficient because common letters get short symbols; PSK31 borrows the idea with a self-synchronising twist. Varicode (Martinez, G3PLX) is a prefix-free, variable-length binary code with two defining structural properties:

  1. No codeword contains two consecutive zeros (00).
  2. Consequently, every codeword both begins and ends with 1.

Because 00 can never occur inside a codeword, the sequence 00 is reserved as the inter-character separator. The receiver simply accumulates bits and cuts a character every time it sees 00. Short codewords are assigned to frequent characters (space = 1, e = 11, t = 101), so English text runs at roughly 50 words per minute at 31.25 baud. Both properties are verified to hold for all 256 entries of this repo's table (§6).

2.3 The raised-cosine reversal envelope

A hard 180° phase flip is a discontinuity, and discontinuities have broad spectra — keyed square-wave BPSK would splatter far beyond its baud. PSK31 instead shapes the amplitude so that the carrier envelope smoothly dips to zero at the instant of each reversal and returns to full amplitude, following a raised-cosine (half-cosine) curve. When two consecutive symbols agree (no reversal) the amplitude is held at full scale. The effect is that the transmitted signal is a cosine-tapered pulse train whose occupied bandwidth is close to the symbol rate itself (~31 Hz for PSK31). This is the single most important spectral feature of the mode.


3. Signal structure

3.1 Per-variant parameters

The three variants differ only in symbol rate. The baud map is fixed in the app at app/radio/digimodes.py and app/radio/manager.py:

Mode Baud (sym/s) TX samples/symbol @48 kHz RX baseband sps (@12 kHz)
psk31 31.25 1536 16
psk63 62.5 768 16
psk125 125.0 384 16

31.25 baud is the canonical PSK31 rate (8000 / 256); PSK63 and PSK125 double and quadruple it. Every variant is decimated on receive to exactly 16 baseband samples per symbol (§5.2), so the demodulator core is rate-independent.

3.2 Carrier / audio subcarrier

The waveform is a real audio tone (a suppressed-carrier BPSK subcarrier that lives inside the SSB passband), default 1000.0 Hz for both TX and RX (app/radio/psk.py). Transmit audio is generated at 48 kHz (_native_tx, app/radio/manager.py); receive runs at 12 kHz (SR = 12000, app/radio/psk.py) after the app's 4:1 decimation of the 48 kHz capture (_decimate_12k, app/radio/digimodes.py). The RX tone is fixed at 1000 Hz in normal app use (the decoder is constructed with the default tone at app/radio/digimodes.py), so the operator zero-beats the received signal to a 1 kHz audio pitch; PskDecoder.set_tone() (app/radio/psk.py) exists to retune it programmatically.

3.3 Frame layout of a transmission

A full transmission (modulate, app/radio/psk.py) is a single bit string, differentially modulated onto the tone:

[ 32 idle bits "0" ]  [ char1 varicode + "00" ]  [ char2 varicode + "00" ] … [ 32 idle bits "0" ]
   preamble               character                  character                   postamble
   (32 reversals)                                                                 (32 reversals)
  • Preamble: "0" * 32 (app/radio/psk.py) — 32 consecutive 0 bits = 32 phase reversals = the idle tone, giving the receiver a run to acquire symbol timing.
  • Payload: for each character, its varicode codeword followed by the "00" separator (app/radio/psk.py): bits += VARICODE_TX[ord(ch) & 0xFF] + "00". The & 0xFF clamps to a byte so the 256-entry table is always in range.
  • Postamble: another "0" * 32 (app/radio/psk.py).

3.4 Bit → phase convention (differential encoding)

The bit string is turned into a sequence of BPSK symbol values ∈ {+1, −1} (app/radio/psk.py):

s = 1.0
syms = [s]
for b in bits:
    if b == "0":
        s = -s        # bit 0 -> phase reversal (flip sign)
    syms.append(s)     # bit 1 -> keep phase

So syms has len(bits) + 1 entries; syms[0] = +1 is the arbitrary starting phase (differential detection makes the absolute choice irrelevant). Each 0 toggles the sign; each 1 holds it.


4. Encode / transmit — step by step

Source: modulate(text, sr=48000, baud=31.25, tone=1000.0), app/radio/psk.py. Returns bytes of little-endian signed-16-bit mono PCM.

Step 1 — build the bit string. Preamble, then per-character varicode + "00", then postamble (§3.3).

Step 2 — differential-encode to ±1 symbol values (§3.4).

Step 3 — set up the sample clock and carrier.

sps = sr / baud                 # e.g. 48000 / 31.25 = 1536.0
w   = 2 * math.pi * tone / sr   # per-sample carrier phase increment
carrier = 0.0

Step 4 — render each symbol transition with raised-cosine amplitude shaping. For each symbol index i from 1 to len(syms)-1, with s0 = syms[i-1] (previous) and s1 = syms[i] (current), emit int(sps) samples (app/radio/psk.py):

for k in range(int(sps)):
    frac = k / sps
    base = s0 + (s1 - s0) * (0.5 - 0.5 * math.cos(math.pi * frac))
    carrier += w
    out += struct.pack("<h", int(0.5 * base * math.cos(carrier) * 32767))

The shaping factor (0.5 - 0.5·cos(π·frac)) rises monotonically from 0 at frac = 0 to 1 at frac = 1 (a raised-cosine ramp), so base sweeps smoothly from the previous symbol value s0 to the current one s1 across the symbol window:

  • No reversal (s0 == s1): base is constant at that value → constant full amplitude, phase held.
  • Reversal (s1 = −s0): base = s0·cos(π·frac) sweeps +1 → 0 → −1, so the amplitude envelope |base| dips to zero at the centre of the symbol (frac = 0.5) and the carrier phase flips there. This is the raised-cosine reversal notch of §2.3.

The transmitted sample is 0.5 · base · cos(carrier): a real BPSK tone whose sign (0 or π phase) is the sign of base and whose envelope is |base|. The fixed 0.5 scale (app/radio/psk.py) sets peak output to half full-scale before the × 32767 conversion to int16, leaving headroom.

Note: the modulator applies no explicit ramp-up/ramp-down keying beyond the shaping; it simply starts mid-carrier. The 32-reversal preamble/postamble provide the on/off transient margin and the receiver's acquisition run.


5. Decode / receive — step by step

Source: PskDecoder, app/radio/psk.py. Constructed as PskDecoder(on_text, sr=12000, baud=31.25, tone=1000.0); the app feeds it 12 kHz signed-16 PCM via feed() (app/radio/digimodes.py). There is no carrier PLL and no Costas loop — differential detection tolerates a static tone offset up to ~±baud/4 (app/radio/psk.py).

5.1 State and derived constants (reset, app/radio/psk.py)

self.sps   = self.sr / self.baud              # input samples/symbol (e.g. 384)
self._dec  = max(1, int(self.sps / 16))       # decimation to ~16 sps  (e.g. 24)
self._lp   = 0j                               # single-pole LPF state
self._alpha = 1.0 / (self.sps / 4)            # LPF coefficient (e.g. 1/96)
self._sbb  = self.sr / self._dec              # baseband sample rate  (e.g. 500)
self._spb  = self._sbb / self.baud            # baseband samples/symbol = 16.0
self._prev_sym = 1 + 0j                        # previous symbol (differential ref)
self._bits = ""                                # accumulated demodulated bits

For all three variants self._spb works out to 16.0:

Mode sps = 12000/baud _dec = int(sps/16) _sbb = 12000/_dec _spb = _sbb/baud _alpha = 4/sps
psk31 384 24 500 16.0 1/96 ≈ 0.0104
psk63 192 12 1000 16.0 1/48 ≈ 0.0208
psk125 96 6 2000 16.0 1/24 ≈ 0.0417

5.2 Downconvert, low-pass, decimate (feed, app/radio/psk.py)

Odd trailing bytes are buffered in self._carry so only whole samples are processed (app/radio/psk.py). For each input sample v (normalised by /32768.0):

w = 2 * math.pi * self.tone / self.sr
self._nco -= w                                # accumulate NEGATIVE carrier phase
if self._nco < -2 * math.pi:                 # cheap wrap
    self._nco += 2 * math.pi
z = v * cmath.exp(1j * self._nco)            # complex mix tone down to DC
self._lp += self._alpha * (z - self._lp)     # one-pole IIR low-pass
self._decphase += 1                          # decimate by self._dec
if self._decphase >= self._dec:
    self._decphase = 0
    self._bb.append(self._lp)                # push a baseband sample

Multiplying the real input by e^{−jωn} shifts the +1000 Hz tone to 0 Hz; the one-pole low-pass (_alpha ≈ 4/sps, a time constant of about a quarter-symbol) removes the image at −2000 Hz and out-of-band noise; keeping every _dec-th output decimates the complex baseband to ~16 samples/symbol. After ingesting the chunk, feed calls _demod().

5.3 Symbol timing and integrate-and-dump (_demod, app/radio/psk.py)

While at least two symbols of baseband are buffered, one symbol is integrated at the current timing phase:

spb = self._spb                              # 16.0
i0  = int(self._sym_phase)
i1  = int(self._sym_phase + spb)
seg = self._bb[i0:i1]
sym = sum(seg) / len(seg)                     # boxcar (matched) integrate-and-dump
self._on_symbol(sym)
self._sym_phase += spb                        # advance one symbol
# consume whole baseband samples to keep the buffer bounded:
if self._sym_phase >= spb:
    drop = int(self._sym_phase)
    del self._bb[:drop]
    self._sym_phase -= drop

The per-symbol estimate sym is the mean of the 16 baseband samples spanning one symbol — a boxcar matched filter for the (nominally rectangular, post-LPF) baseband symbol. sym is complex: its argument is the symbol's carrier phase, its magnitude the symbol energy.

Honest note on timing recovery. The comment at app/radio/psk.py ("Gardner-ish timing: compare magnitude at mid vs edges to nudge") describes an intent that the code does not implement: _sym_phase is only ever advanced by a fixed spb per symbol — there is no error-driven nudge, no interpolation, and self._acc (app/radio/psk.py) is unused. In practice this is a fixed-rate, fixed-phase integrate-and-dump. It works because (a) _spb is exactly the integer 16 for all three modes, so there is no slow phase creep, and (b) differential BPSK with a boxcar integrator is tolerant of modest timing error. The cost is sensitivity to sample-clock offset between TX and RX over long overs; there is no closed-loop tracking to absorb it.

5.4 Differential detection and bit decision (_on_symbol, app/radio/psk.py)

if abs(self._prev_sym) < 1e-9 or abs(sym) < 1e-9:
    bit = 1                                              # degenerate/zero energy -> "1"
else:
    dot = (sym * self._prev_sym.conjugate()).real
    bit = 0 if dot < 0 else 1                            # reversal -> 0, else 1
self._prev_sym = sym
self._bits += "1" if bit else "0"

sym · conj(prev_sym) is the complex phase difference between this symbol and the last; its real part is |sym|·|prev|·cos(Δφ). A phase reversal (Δφ ≈ π) makes the real part negative → bit 0; a held phase (Δφ ≈ 0) makes it positive → bit 1. This is exactly the DBPSK convention of §2.1, and it needs no absolute phase reference. Near-zero-energy symbols default to 1 (no reversal) to avoid spurious bits during dropouts.

5.5 Varicode framing on 00 (app/radio/psk.py)

if self._bits.endswith("00"):
    token = self._bits[:-2]                              # strip the separator
    self._bits = ""
    if token:
        c = DECODE.get(token)
        if c is not None and 0 < c < 128 and c not in (0x0E, 0x0F):
            ch = chr(c)
            if ch.isprintable() or ch in "\r\n\t":
                self.on_text(ch)
if len(self._bits) > 64:                                 # runaway guard on noise
    self._bits = self._bits[-32:]

Every time the accumulated bit string ends in 00, the leading part is a candidate varicode codeword. It is looked up in DECODE (§6.2); the result is emitted only if it is a printable ASCII character in 1…127, excluding 0x0E/0x0F (Shift-Out / Shift-In), and either str.isprintable() or one of \r \n \t. Empty tokens (which occur continuously during the idle reversal stream, where 00 repeats) are silently skipped. If more than 64 bits accumulate without ever seeing 00 — pure noise, no valid framing — the buffer is trimmed to its last 32 bits to bound memory and let sync re-establish.

5.6 End-to-end RX pipeline

12 kHz s16 ─▶ ×e^{-jωn} ─▶ 1-pole LPF ─▶ ÷_dec ─▶ integrate 16 bb/sym ─▶ sym·conj(prev) ─▶ bit ─▶ accumulate ─▶ "00"? ─▶ DECODE ─▶ char

Stages, left to right: complex downconvert (mix by e^{−jωn}) to baseband, a one-pole low-pass, decimate by _dec to ~16 baseband samples per symbol, boxcar integrate-and-dump over one symbol, differential detection sym·conj(prev) → sign of the real part → bit, bit accumulation, and a 00 test that cuts a varicode token and looks it up in DECODE.


6. Constants and tables

6.1 Baud constants

psk31  -> 31.25 baud
psk63  -> 62.5  baud
psk125 -> 125.0 baud

Fixed in the mode→baud map at app/radio/digimodes.py and app/radio/manager.py. Default carrier tone 1000.0 Hz; RX default rate SR = 12000 (app/radio/psk.py); TX default rate 48000.

6.2 The varicode table

The varicode is a fixed 256-entry lookup: VARICODE_TX[byte] is the bit-string codeword for byte value 0…255, transcribed from fldigi's src/psk/pskvaricode.cxx (Dave Freese W1HKJ, GPL-3; the varicode itself is G3PLX's published standard). The reverse map used on receive is just the inverse:

DECODE = {v: i for i, v in enumerate(VARICODE_TX) if v}

All 256 codewords are non-empty and unique, so DECODE has 256 entries with no collisions. The two structural invariants hold for every entry (verified over the whole table below): no codeword contains 00, and every codeword starts and ends with 1 (§2.2) — these are what make the 00 separator unambiguous. Indices 0–31 are the C0 control characters, 32–126 the printable ASCII set, 127 DEL, and 128–255 the extended range (present in the table but never emitted on RX — the 0 < c < 128 gate in psk.py rejects them).

Quick reference for the most common characters:

char idx codeword char idx codeword
NUL 0 1010101011 space 32 1
LF (\n) 10 11101 ! 33 111111111
CR (\r) 13 11111 0 48 10110111
e 101 11 A 65 1111101
t 116 101 a 97 1011
o 111 111 i 105 1101
n 110 1111 s 115 10111

The complete table, verbatim — the authoritative artifact; copy it exactly, since a single wrong bit breaks framing for that character (VARICODE_TX[byte], index = ASCII/byte value 0…255):

VARICODE_TX = [
    "1010101011",   #   0  NUL
    "1011011011",   #   1  SOH
    "1011101101",   #   2  STX
    "1101110111",   #   3  ETX
    "1011101011",   #   4  EOT
    "1101011111",   #   5  ENQ
    "1011101111",   #   6  ACK
    "1011111101",   #   7  BEL
    "1011111111",   #   8  BS
    "11101111",     #   9  HT
    "11101",        #  10  LF
    "1101101111",   #  11  VT
    "1011011101",   #  12  FF
    "11111",        #  13  CR
    "1101110101",   #  14  SO
    "1110101011",   #  15  SI
    "1011110111",   #  16  DLE
    "1011110101",   #  17  DC1
    "1110101101",   #  18  DC2
    "1110101111",   #  19  DC3
    "1101011011",   #  20  DC4
    "1101101011",   #  21  NAK
    "1101101101",   #  22  SYN
    "1101010111",   #  23  ETB
    "1101111011",   #  24  CAN
    "1101111101",   #  25  EM
    "1110110111",   #  26  SUB
    "1101010101",   #  27  ESC
    "1101011101",   #  28  FS
    "1110111011",   #  29  GS
    "1011111011",   #  30  RS
    "1101111111",   #  31  US
    "1",            #  32  SP
    "111111111",    #  33  !
    "101011111",    #  34  "
    "111110101",    #  35
    "111011011",    #  36  $
    "1011010101",   #  37  %
    "1010111011",   #  38  &
    "101111111",    #  39  '
    "11111011",     #  40  (
    "11110111",     #  41  )
    "101101111",    #  42  *
    "111011111",    #  43  +
    "1110101",      #  44  ,
    "110101",       #  45  -
    "1010111",      #  46  .
    "110101111",    #  47  /
    "10110111",     #  48  0
    "10111101",     #  49  1
    "11101101",     #  50  2
    "11111111",     #  51  3
    "101110111",    #  52  4
    "101011011",    #  53  5
    "101101011",    #  54  6
    "110101101",    #  55  7
    "110101011",    #  56  8
    "110110111",    #  57  9
    "11110101",     #  58  :
    "110111101",    #  59  ;
    "111101101",    #  60  <
    "1010101",      #  61  =
    "111010111",    #  62  >
    "1010101111",   #  63  ?
    "1010111101",   #  64  @
    "1111101",      #  65  A
    "11101011",     #  66  B
    "10101101",     #  67  C
    "10110101",     #  68  D
    "1110111",      #  69  E
    "11011011",     #  70  F
    "11111101",     #  71  G
    "101010101",    #  72  H
    "1111111",      #  73  I
    "111111101",    #  74  J
    "101111101",    #  75  K
    "11010111",     #  76  L
    "10111011",     #  77  M
    "11011101",     #  78  N
    "10101011",     #  79  O
    "11010101",     #  80  P
    "111011101",    #  81  Q
    "10101111",     #  82  R
    "1101111",      #  83  S
    "1101101",      #  84  T
    "101010111",    #  85  U
    "110110101",    #  86  V
    "101011101",    #  87  W
    "101110101",    #  88  X
    "101111011",    #  89  Y
    "1010101101",   #  90  Z
    "111110111",    #  91  [
    "111101111",    #  92  \\
    "111111011",    #  93  ]
    "1010111111",   #  94  ^
    "101101101",    #  95  _
    "1011011111",   #  96  `
    "1011",         #  97  a
    "1011111",      #  98  b
    "101111",       #  99  c
    "101101",       # 100  d
    "11",           # 101  e
    "111101",       # 102  f
    "1011011",      # 103  g
    "101011",       # 104  h
    "1101",         # 105  i
    "111101011",    # 106  j
    "10111111",     # 107  k
    "11011",        # 108  l
    "111011",       # 109  m
    "1111",         # 110  n
    "111",          # 111  o
    "111111",       # 112  p
    "110111111",    # 113  q
    "10101",        # 114  r
    "10111",        # 115  s
    "101",          # 116  t
    "110111",       # 117  u
    "1111011",      # 118  v
    "1101011",      # 119  w
    "11011111",     # 120  x
    "1011101",      # 121  y
    "111010101",    # 122  z
    "1010110111",   # 123  {
    "110111011",    # 124  |
    "1010110101",   # 125  }
    "1011010111",   # 126  ~
    "1110110101",   # 127  DEL
    "1110111101",   # 128  0x80
    "1110111111",   # 129  0x81
    "1111010101",   # 130  0x82
    "1111010111",   # 131  0x83
    "1111011011",   # 132  0x84
    "1111011101",   # 133  0x85
    "1111011111",   # 134  0x86
    "1111101011",   # 135  0x87
    "1111101101",   # 136  0x88
    "1111101111",   # 137  0x89
    "1111110101",   # 138  0x8A
    "1111110111",   # 139  0x8B
    "1111111011",   # 140  0x8C
    "1111111101",   # 141  0x8D
    "1111111111",   # 142  0x8E
    "10101010101",  # 143  0x8F
    "10101010111",  # 144  0x90
    "10101011011",  # 145  0x91
    "10101011101",  # 146  0x92
    "10101011111",  # 147  0x93
    "10101101011",  # 148  0x94
    "10101101101",  # 149  0x95
    "10101101111",  # 150  0x96
    "10101110101",  # 151  0x97
    "10101110111",  # 152  0x98
    "10101111011",  # 153  0x99
    "10101111101",  # 154  0x9A
    "10101111111",  # 155  0x9B
    "10110101011",  # 156  0x9C
    "10110101101",  # 157  0x9D
    "10110101111",  # 158  0x9E
    "10110110101",  # 159  0x9F
    "10110110111",  # 160  0xA0
    "10110111011",  # 161  0xA1
    "10110111101",  # 162  0xA2
    "10110111111",  # 163  0xA3
    "10111010101",  # 164  0xA4
    "10111010111",  # 165  0xA5
    "10111011011",  # 166  0xA6
    "10111011101",  # 167  0xA7
    "10111011111",  # 168  0xA8
    "10111101011",  # 169  0xA9
    "10111101101",  # 170  0xAA
    "10111101111",  # 171  0xAB
    "10111110101",  # 172  0xAC
    "10111110111",  # 173  0xAD
    "10111111011",  # 174  0xAE
    "10111111101",  # 175  0xAF
    "10111111111",  # 176  0xB0
    "11010101011",  # 177  0xB1
    "11010101101",  # 178  0xB2
    "11010101111",  # 179  0xB3
    "11010110101",  # 180  0xB4
    "11010110111",  # 181  0xB5
    "11010111011",  # 182  0xB6
    "11010111101",  # 183  0xB7
    "11010111111",  # 184  0xB8
    "11011010101",  # 185  0xB9
    "11011010111",  # 186  0xBA
    "11011011011",  # 187  0xBB
    "11011011101",  # 188  0xBC
    "11011011111",  # 189  0xBD
    "11011101011",  # 190  0xBE
    "11011101101",  # 191  0xBF
    "11011101111",  # 192  0xC0
    "11011110101",  # 193  0xC1
    "11011110111",  # 194  0xC2
    "11011111011",  # 195  0xC3
    "11011111101",  # 196  0xC4
    "11011111111",  # 197  0xC5
    "11101010101",  # 198  0xC6
    "11101010111",  # 199  0xC7
    "11101011011",  # 200  0xC8
    "11101011101",  # 201  0xC9
    "11101011111",  # 202  0xCA
    "11101101011",  # 203  0xCB
    "11101101101",  # 204  0xCC
    "11101101111",  # 205  0xCD
    "11101110101",  # 206  0xCE
    "11101110111",  # 207  0xCF
    "11101111011",  # 208  0xD0
    "11101111101",  # 209  0xD1
    "11101111111",  # 210  0xD2
    "11110101011",  # 211  0xD3
    "11110101101",  # 212  0xD4
    "11110101111",  # 213  0xD5
    "11110110101",  # 214  0xD6
    "11110110111",  # 215  0xD7
    "11110111011",  # 216  0xD8
    "11110111101",  # 217  0xD9
    "11110111111",  # 218  0xDA
    "11111010101",  # 219  0xDB
    "11111010111",  # 220  0xDC
    "11111011011",  # 221  0xDD
    "11111011101",  # 222  0xDE
    "11111011111",  # 223  0xDF
    "11111101011",  # 224  0xE0
    "11111101101",  # 225  0xE1
    "11111101111",  # 226  0xE2
    "11111110101",  # 227  0xE3
    "11111110111",  # 228  0xE4
    "11111111011",  # 229  0xE5
    "11111111101",  # 230  0xE6
    "11111111111",  # 231  0xE7
    "101010101011", # 232  0xE8
    "101010101101", # 233  0xE9
    "101010101111", # 234  0xEA
    "101010110101", # 235  0xEB
    "101010110111", # 236  0xEC
    "101010111011", # 237  0xED
    "101010111101", # 238  0xEE
    "101010111111", # 239  0xEF
    "101011010101", # 240  0xF0
    "101011010111", # 241  0xF1
    "101011011011", # 242  0xF2
    "101011011101", # 243  0xF3
    "101011011111", # 244  0xF4
    "101011101011", # 245  0xF5
    "101011101101", # 246  0xF6
    "101011101111", # 247  0xF7
    "101011110101", # 248  0xF8
    "101011110111", # 249  0xF9
    "101011111011", # 250  0xFA
    "101011111101", # 251  0xFB
    "101011111111", # 252  0xFC
    "101101010101", # 253  0xFD
    "101101010111", # 254  0xFE
    "101101011011", # 255  0xFF
]

7. Interoperability and validation

7.1 On-air compatibility

Because the waveform matches the published PSK31 standard on every axis that matters — 31.25 baud DBPSK, reversal-encodes-0 convention, raised-cosine reversal envelope, 1 kHz suppressed-carrier tone, and the G3PLX varicode with 00 framing — Rafe's modulate() output is decodable by standard PSK31 software (fldigi, DM780, MixW) and, conversely, PskDecoder decodes real off-air PSK31 from those stations, provided the signal is tuned to the 1 kHz audio pitch the fixed RX tone expects. PSK63 and PSK125 interoperate identically with the corresponding fldigi modes. The varicode provenance and standard are credited in NOTICE.md (fldigi / G3PLX).

7.2 Loopback self-test

test_psk.py is a closed-loop round-trip test. It synthesises DBPSK audio independently of the modulator — building the same varicode-plus-00 bit stream, mapping 0→phase reversal, adding white Gaussian noise at a target SNR (test_psk.py) — then feeds it to PskDecoder at SR = 12000, 31.25 baud, 1000 Hz, in 2400-byte chunks (test_psk.py) and asserts the transmitted text appears in the decode. The bundled cases (test_psk.py):

text SNR
CQ CQ DE M0ABC 25 dB
the quick brown fox 599 20 dB
TEST DE G4XYZ K 15 dB

The test passes when all three decode (test_psk.py). This independently exercises the downconvert → LPF → decimate → integrate → differential-detect → varicode-frame chain against a noisy channel, and cross-checks the varicode 00-framing end to end. (Note the harness synthesises with a simplified per-symbol envelope — a full raised-cosine round-trip against modulate() is covered on-air, not in this unit test.)

7.3 App integration

  • TX: _native_tx (app/radio/manager.py) sanitises the text to printable ASCII 32…126, caps it at 500 characters, calls psk_mod(text, sr=48000, baud, tone=1000.0), keys PTT and streams the PCM.
  • RX: incoming 48 kHz audio is decimated 4:1 to 12 kHz (_decimate_12k, app/radio/digimodes.py) and handed to PskDecoder.feed(). Decoded characters flow to self._on_native_char.
  • Selection: the mode buttons psk31/psk63/psk125 in the web UI (app/static/index.html) route to the baud map at app/radio/digimodes.py.

8. Limitations

An honest inventory of what this implementation does and does not do.

  • BPSK only — no QPSK. The PSK31 standard also defines a QPSK variant that carries a rate-½, constraint-length-5 convolutional code (Viterbi-decoded) for error correction. It is not implemented: there is no FEC, no Viterbi, and no QPSK constellation anywhere in app/radio/psk.py. Only the plain, uncoded BPSK waveform is supported.
  • No FEC of any kind. A varicode framing error (a corrupted 00, or a bit slip) simply drops or garbles the affected character; there is no coding to recover it.
  • Only 31/63/125 baud. PSK250/PSK500/PSK1000 and the RTTY-style multi-carrier PSKxxxR robust variants are not implemented.
  • No true symbol-timing recovery. Despite the "Gardner-ish" comment (app/radio/psk.py), the demodulator does a fixed-phase integrate-and-dump with no error-driven timing correction (§5.3). It relies on _spb being exactly 16 and on a reasonably accurate sample clock; there is no closed loop to track clock drift over a long transmission.
  • No AFC / no carrier PLL. The RX tone is fixed (1000 Hz by default); tolerance to a static tuning offset is only about ±baud/4 by virtue of differential detection (app/radio/psk.py). The operator must zero-beat the signal; there is no automatic frequency tracking and no waterfall click-to-tune in this module (the decoder's set_tone() must be driven externally).
  • Extended varicode not emitted. Codewords for byte values 128–255 exist in VARICODE_TX but the RX gate 0 < c < 128 (app/radio/psk.py) rejects them, along with 0x0E/0x0F; only printable ASCII (plus \r \n \t) reaches on_text.
  • Single fixed detection filter. The one-pole low-pass and boxcar integrator are a lightweight matched filter, not a true root-raised-cosine matched filter, so performance is a little below an optimal PSK31 receiver at low SNR.

None of these are architectural dead-ends — QPSK+FEC, an AFC loop, and a Gardner timing correction could each be added on top of the existing baseband path — but as implemented the mode is uncoded BPSK31/63/125 with fixed-tone, fixed-timing differential detection.


9. References

  1. P. Martinez, G3PLX, "PSK31: A new radio-teletype mode," RadCom, Dec 1998 / Jan 1999 — the original PSK31 specification: DBPSK, 31.25 baud, the varicode, and the raised-cosine reversal envelope.
  2. fldigi (Dave Freese, W1HKJ et al.), src/psk/pskvaricode.cxx, GPL-3 — the varicode table transcribed here (app/radio/pskvaricode.py), credited in NOTICE.md.
  3. J. G. Proakis, Digital Communications — differential PSK, matched filtering, and integrate-and-dump detection.
  4. In-repo source of truth: app/radio/psk.py (modem) and app/radio/pskvaricode.py (table); app wiring in app/radio/digimodes.py (RX) and app/radio/manager.py (TX); tests in test_psk.py.