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:
- No codeword contains two consecutive zeros (
00). - 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 consecutive0bits = 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& 0xFFclamps 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):baseis 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_phaseis only ever advanced by a fixedspbper symbol — there is no error-driven nudge, no interpolation, andself._acc(app/radio/psk.py) is unused. In practice this is a fixed-rate, fixed-phase integrate-and-dump. It works because (a)_spbis 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 ASCII32…126, caps it at 500 characters, callspsk_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 toPskDecoder.feed(). Decoded characters flow toself._on_native_char. - Selection: the mode buttons
psk31/psk63/psk125in the web UI (app/static/index.html) route to the baud map atapp/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_spbbeing 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'sset_tone()must be driven externally). - Extended varicode not emitted. Codewords for byte values 128–255 exist in
VARICODE_TXbut the RX gate0 < c < 128(app/radio/psk.py) rejects them, along with0x0E/0x0F; only printable ASCII (plus\r \n \t) reacheson_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
- 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.
- fldigi (Dave Freese, W1HKJ et al.),
src/psk/pskvaricode.cxx, GPL-3 — the varicode table transcribed here (app/radio/pskvaricode.py), credited inNOTICE.md. - J. G. Proakis, Digital Communications — differential PSK, matched filtering, and integrate-and-dump detection.
- In-repo source of truth:
app/radio/psk.py(modem) andapp/radio/pskvaricode.py(table); app wiring inapp/radio/digimodes.py(RX) andapp/radio/manager.py(TX); tests intest_psk.py.