Beyond Voice, Part 2: AFSK & FFSK — Two Tones, One Bit

Part 2 of Beyond Voice, a 14-part deep dive into everything GopherTrunk decodes that is not a trunked voice call, and the one eleven-place wiring pattern that carries each of them from a burst on the air to a row in the web console. Part 1 fixed the pattern and noted that the three AFSK front ends run an identical DSP chain, differing only in tone pair, line code and slicer. This part is that chain — the physics every in-band signalling decoder shares before a protocol bit exists — and the place a “harmless” slicer refinement cost a round.

TL;DR: MDC1200, FleetSync, MPT 1327 and APRS all ride as two audio tones inside an FM voice channel. GopherTrunk’s front ends (internal/radio/{aprs,mdc1200,fleetsync}/afsk) run one chain: demod.FMdsp.RealResampler to 9600 Hz (1200 baud × Oversample 8) → demod.FFSK, which mixes the audio down by the tone midpoint, low-passes with a 60 dB Kaiser filter and FM-discriminates the complex baseband so the output is positive for mark → sync.MuellerMuller at 8 sps, gain 0.05 → a slicer. CCIR FFSK’s 1200/1800 Hz tones hold exactly 1 and 1.5 cycles per bit; Bell 202’s 2200 Hz does not. The slicer is where the front ends part: APRS and MDC1200 track DC with a 1/64 EMA, while FleetSync slices at zero — a 1/512 bias tracker drifted toward a 68-symbol run of space tone and flipped 23 bits of a 260-bit FS-II frame.

Key takeaways

  • In-band data is audio first. Two tones modulate an ordinary FM transmitter; the receiver’s first stage is the voice discriminator.
  • FFSK is AFSK with integer-cycle tones. At 1200 baud a 1200 Hz mark is one cycle per bit and an 1800 Hz space is 1.5; phase stays continuous at every bit edge. Bell 202’s 2200 Hz breaks that.
  • The tone detector is itself an FM demodulator. demod.FFSK treats the two tones as a ±300 Hz (or ±500 Hz) deviation around a midpoint and discriminates the complex baseband, removing carrier offset as a side effect.
  • A slicer threshold is a protocol decision, not a DSP one. FleetSync II’s long space runs made a DC tracker harmful where AX.25’s flags keep it benign.

Cheat sheet

Concern What it does Where it lives
FM discriminator IQ → phase derivative in rad/sample internal/dsp/demod/fm.go (demod.FM.Process)
Audio-rate resampler polyphase L/M to baud × Oversample internal/dsp/resampler_real.go (dsp.NewRealResampler)
Tone discriminator mix by midpoint → Kaiser LPF → FM discriminate internal/dsp/demod/ffsk.go (demod.NewFFSK, Discriminate)
Symbol timing closed-loop MM at 8 sps, chunk-safe internal/dsp/sync/clock.go (sync.NewMuellerMuller)
DC-tracking slicer 1/64 EMA threshold, then NRZI or NRZ aprs/afsk/receiver.go, mdc1200/afsk/receiver.go (feedSymbol)
Zero-threshold slicer s > 0 → mark, polarity left to the framer fleetsync/afsk/receiver.go (feedSymbol)

In this post

  • Two tones, one bit — AFSK, FFSK and the integer-cycle property.
  • From IQ to 9600 Hz audio — discriminator and resampler.
  • The tone discriminator — how demod.FFSK mixes, filters and decides.
  • Timing at eight samples per bit — Mueller-Müller and chunk boundaries.
  • The slicer that cost a round — DC tracking, zero thresholds, line codes.

Two tones, one bit

Every protocol here shifts an audio tone between two pitches and feeds it into a stock FM transmitter’s microphone path; the RF stage never knows. The AFSK reference covers the family, the FFSK page the coherent cousin, and the distinction is arithmetic.

At 1200 baud a bit lasts 1/1200 s. A 1200 Hz tone completes exactly one cycle in that period; an 1800 Hz tone exactly 1.5. Both cross zero at every bit boundary, so the transmitter switches tones with no phase discontinuity — a continuous-phase waveform a saturated FM channel passes cleanly. That is fast FSK, the CCIR pair MDC1200, FleetSync and MPT 1327 use. Bell 202, which APRS inherits through AX.25, keys 1200 and 2200 Hz: the space completes 1.833 cycles per bit, so bit-edge phase depends on history. The front ends encode the choice as two constants:

Front end Mark / space Baud Line code Slicer
aprs/afsk (Bell 202) 1200 / 2200 Hz 1200 NRZI DC-tracking
mdc1200/afsk (CCIR) 1200 / 1800 Hz 1200 NRZ DC-tracking
fleetsync/afsk (CCIR) 1200 / 1800 Hz 1200 (2400 accepted) NRZ fixed zero
mpt1327/receiver, dsc/ffsk 1200 / 1800, 1300 / 2100 Hz 1200 control-channel paths

Mark is binary 1 in every one of them — the CCIR convention demod.FFSK bakes into its output sign; invertSlice keeps positive meaning mark whichever tone is higher.

From IQ to 9600 Hz audio

The first two stages are the ones every FM voice path in the tree uses (SDR Internals Part 6). demod.FM is the quadrature discriminator — arg(z[n]·conj(z[n−1])) in radians per sample. Its output is the transmitter’s audio plus a DC term for any tuning error, which matters shortly. That audio is resampled to the rate the tone detector wants, defined identically in all three front ends:

// internal/radio/mdc1200/afsk/receiver.go (shape) — identical in aprs/afsk
const Oversample  = 8                     // discriminator samples per bit
const mmGain      = 0.05
const AudioRateHz = BaudHz * Oversample   // 1200 × 8 = 9600
g := gcd(uint32(AudioRateHz), opts.InputRateHz)
rsmp := dsp.NewRealResampler(int(AudioRateHz/g), int(opts.InputRateHz/g), 16, 7.0)

9600 Hz clears the Nyquist rate of the higher tone and is, the APRS doc notes, “a round divisor of most common SDR sample rates.” dsp.NewRealResampler is the polyphase L/M design of the resampler reference, a Kaiser prototype at cutoff 0.5/max(L, M) so images and aliases are rejected on both legs. FleetSync departs from the fixed 16 taps in one respect: because it may be fed a 48 kHz or 250 kHz stream directly, it sizes tapsPerBranch as 12·M/L, floored at 16 and capped at 512, so a large decimation still rejects the out-of-band energy that would fold onto the tones. The 48 kHz lesson of the C4FM family applies here at 9600.

The tone discriminator

demod.FFSK turns audio into a soft bit by treating the two tones as an FM signal in their own right: a mark at 1200 Hz and a space at 1800 Hz are a 1500 Hz carrier deviated ±300 Hz. So mix the audio down by the midpoint, low-pass to keep only the deviation, and run the same arg(z·conj(last)) discriminator again on the complex baseband:

// internal/dsp/demod/ffsk.go (shape)
func NewFFSK(sampleRate, markHz, spaceHz float64) *FFSK {
    centerHz := (markHz + spaceHz) / 2      // 1500 (CCIR) or 1700 (Bell 202)
    deltaHz := math.Abs(spaceHz-markHz) / 2  // 300 or 500
    cutoff := deltaHz * 1.5 / sampleRate     // passband ends at 1.5 × half-spacing
    // Kaiser LPF for ≥ 60 dB rejection of the mirror at 2·centerHz (≥ 21 taps)
    return &FFSK{
        radPerSample: -2 * math.Pi * centerHz / sampleRate,
        lpf:          filter.NewFIR(filter.LowpassKaiser(n, cutoff, beta)),
        invertSlice:  markHz < spaceHz,
    }
}

Discriminate mixes each sample by e^{−j·2π·centerHz·t}, runs the FIR, then emits atan2 of the product with the previous filtered sample. The output is a signed frequency: positive for mark, negative for space, near zero between bursts.

Two properties fall out free. Carrier offset vanishes. A tuner a few hundred hertz off puts a DC term on the first discriminator’s output — the slicer-bias problem of P25 End to End Part 1. Here that DC is an audio-band constant, and a frequency-derivative of a constant is nothing; TestReceiverToleratesCarrierOffset shifts a FleetSync burst by −600 and +400 Hz and decodes both. The mirror cannot beat. The mix also produces an image near 2·centerHz; the 60 dB stopband keeps it out of the second discriminator, where it would appear as an inter-tone beat.

audio @ 9600 mix by 1500 Hz mark −300, space +300 Kaiser LPF cutoff 450 Hz · 60 dB arg(z · conj(last)) soft bit, + = mark mark −300 space +300 passband ±450 Hz around 0 Hz (was 1500); tuning-error DC sits at 0, removed by the derivative
The tone pair is an FM signal around its own midpoint: mix it to baseband, keep the ±300 Hz deviation, discriminate. A tuning-error constant becomes a frequency the derivative ignores; the mirror never reaches the second discriminator.

Timing at eight samples per bit

The discriminator emits 8 soft samples per bit; the framer wants one. Bridging them is sync.MuellerMuller (SDR Internals Part 7, reference): a sub-sample clock mu advanced by sps per symbol and nudged by gain × error, the error |s[n] − sgn(s[n−1])·s[mid]| — zero when the sampling instant sits at the eye’s centre. NewMuellerMuller(8, 0.05) is what every AFSK front end constructs; the APRS doc records why 0.05: “fast enough to settle inside the AX.25 preamble (~30 flags = 240 bits), slow enough that random payload bytes don’t yank the symbol clock around.” A closed loop rather than POCSAG’s open-loop integrator because Bell 202 audio “is messier than POCSAG direct-FSK at the slicer” — variable AGC, a clock a few hundred ppm off.

The loop carries a scar from the P25 work: its prevTail field holds the last sample of the previous chunk so a symbol boundary landing on src[0] interpolates against the correct predecessor. Without it the walk skipped src[0] every call and lost a sample of clock phase per chunk — invisible on long synthetic buffers, fatal on RTL-sized ~19-symbol chunks (issue #275). TestReceiverIsChunkInvariant pins it here: the same FS-II burst fed in chunks of 1, 37, 500, 4096 and whole-stream samples must yield the identical ANI.

The slicer that cost a round

After the loop one soft sample per bit remains, and here the three front ends disagree — a protocol fact wearing DSP clothes. APRS and MDC1200 slice against a slow mean:

// internal/radio/aprs/afsk/receiver.go (shape) — mdc1200/afsk is identical
func (r *Receiver) feedSymbol(s float32) {
    r.meanEMA += (s - r.meanEMA) * (1.0 / 64.0) // ~50 ms memory at 1200 baud
    var raw byte
    if s > r.meanEMA { raw = 1 }
    r.inner.Push(r.nrzi.Decode(raw)) // MDC1200: r.inner.Push(raw) — plain NRZ
}

The tracker absorbs residual DC from imperfect tuning, and for AX.25’s 0x7E flag preamble — alternating enough to keep the mean honest — it is benign. FleetSync tried it and measured the opposite. fleetsync/afsk’s feedSymbol slices at a fixed zero, and its comment is the post-mortem: a FleetSync II frame whose word1 nibbles are small “opens with ~68 symbols of continuous space tone”; a 1/512-per-symbol bias tracker “drifted ~12% of a symbol toward it,” and the ISI-attenuated isolated bits that follow — the weakest samples in the frame — flipped. On one synthesised 260-bit frame: 23 errors with the tracker, zero with the fixed threshold. The reference decoder slices on the sign of the accumulated deviation, and after the previous section zero is also correct: the discriminator output is DC-free by construction, so a tracker has nothing to remove and everything to break. Polarity inversion is handled a layer up by the framer locking on the complemented sync word.

The last step is the line code: “Unlike APRS the line code is plain NRZ, not NRZI,” the FleetSync package doc says. AX.25 transmitters NRZI-encode — a 0 is a tone transition, a 1 no transition — so aprs/afsk.NRZIDecoder emits 1 when the raw bit matches the previous one and 0 when it differs, making AX.25 indifferent to which tone is “high” (NRZI reference). MDC1200 and FleetSync carry the bit on the tone directly, so tone-sense ambiguity is resolved by sync-word complement instead.

How the tone pair shaped the Go code

  • One demodulator, parameterised by two frequencies. demod.NewFFSK derives midpoint, deviation, cutoff and filter length from markHz/spaceHz; its five users differ by two constants.
  • Rates are named constants, not arguments. AudioRateHz = BaudHz × Oversample, the resampler ratio reduced by GCD from whatever the SDR delivers — 9600 Hz is as fixed a design point as 48 kHz is for C4FM.
  • The slicer is per protocol. Each feedSymbol lives in the protocol’s own afsk package, because the right threshold is a property of the frame’s bit statistics.
  • Every stage is chunk-safe and resettable. prevTail, last, LPF history and mixer phase persist across Process calls.

Where this goes next

A stream of sliced bits is where the shared chain ends and a protocol begins. Part 3 takes the MDC1200 path from here: the 40-bit sync word and its five-error tolerance, the 16×7 interleave, the reflected CRC-16, the opcode table, and the wiring that made it the template later signalling decoders were cloned from.

FAQ

What is the difference between AFSK and FFSK? Both send bits as two audio tones through an FM transmitter. FFSK (fast FSK) chooses tones that are integer or half-integer multiples of the bit rate — 1200 and 1800 Hz at 1200 baud hold exactly 1 and 1.5 cycles per bit — so the phase is continuous at bit edges. Bell 202’s 2200 Hz space is not.

Why does GopherTrunk demodulate the tones with a second FM discriminator? Because a mark/space pair is an FM signal around its own midpoint: 1200/1800 Hz is a 1500 Hz carrier deviated ±300 Hz. Mixing by the midpoint, low-passing and discriminating gives a signed frequency, positive for mark, and removes tuning-error DC as a side effect.

Why 9600 Hz and eight samples per bit? 1200 baud × 8 is above the Nyquist rate of the highest tone (2200 Hz), gives the Mueller-Müller loop enough sub-sample resolution to track the clock, and divides evenly into most SDR sample rates so the resampler stays small.

Why does FleetSync slice at zero when APRS tracks DC? FleetSync II frames can open with about 68 symbols of continuous space tone; a 1/512 bias tracker drifted toward that run and flipped the weak isolated bits that followed — 23 errors in one synthesised 260-bit frame, zero with a fixed threshold. The discriminator output is already DC-free, so the sign slicer is simpler and correct.

How does the receiver cope with an inverted FM discriminator? Per line code. APRS is NRZI, where a 0 is a transition, so tone sense is irrelevant by construction. MDC1200 and FleetSync are plain NRZ, so their framers hunt the sync word and its bitwise complement and invert the payload when they lock on the complement.

Series navigation

Part 2 of 14 · ← Part 1: Why a Scanner Decodes Data — The Eleven-Place Pattern · Next → Part 3: MDC1200 — The Template Decoder