TETRA End to End, Part 8: Going Soft — Soft-Decision TCH/S

Part 8 of TETRA End to End, a 14-part deep dive into how GopherTrunk turns one real 25 kHz TETRA carrier into clear recorded voice. Part 7 closed the conformance loop — bit-identical PCM against the ETSI reference codec, twice over — so the vocoder and the channel coding are proven. And yet a marginal same-carrier call still came out short and garbled. The chain was correct; it was also throwing away information at the very first decision it made. This part is about keeping that information: the soft-decision TCH/S path, which carries the demodulator’s *confidence all the way into the Viterbi decoder instead of flattening it into bits at the slicer.*

TL;DR: Hard-decision TCH/S decoding failed ~70% of a marginal same-carrier call’s bursts — every slicer decision discarded how sure the demod was. The soft path keeps that confidence: the receiver’s SoftSink emits the complex π/4-DQPSK differential s·conj(prev) per symbol, the TrafficExtractor carries it in strict lockstep with the dibits (StashSoft, softBuf), softType5FromDiffs turns each differential into two per-bit LLRs, framing.DescrambleTetraSoft applies the colour-code sign flips, and tetra.DecodeTCHSSoft runs soft depuncture + soft Viterbi (framing.DecodeRCPCTetraMotherSoft) to the same class-2 CRC gate. The composer tries soft first and falls back to the hard TCHSpeechFrames when no soft info was stashed — the hard path is byte-identical to before.

Key takeaways

  • A hard slicer is an information shredder. The differential’s angle says which dibit; its magnitude and distance from the decision boundary say how confident. Hard decision keeps the first and burns the second — exactly the part a Viterbi decoder can spend.
  • The soft information is the differential itself. For π/4-DQPSK the two on-air bits’ LLRs are the imaginary and real parts of s·conj(prev) — no separate estimator, the demod already computed it.
  • Lockstep or nothing. softBuf is either exactly parallel to the dibit buffer or empty; on any misalignment the extractor drops the soft path for that burst rather than decode with shifted LLRs. Misaligned soft data is worse than none.
  • The CRC is still the gate. TCHSpeechFramesSoft returns nil on a class-2 CRC failure exactly like the hard gate — soft decision recovers more real bursts; it never admits fake ones.

Cheat sheet

Concern What it does Where it lives
Soft emission per-symbol complex differential, 1:1 with dibits internal/radio/tetra/receiver/receiver.go (Options.SoftSink)
Soft carry stash differentials for the next Process call internal/radio/tetra/traffic.go (TrafficExtractor.StashSoft)
Lockstep buffer softBuf strictly parallel to buf, or empty internal/radio/tetra/traffic.go (Process, softFrame)
Diff → LLR rotation-aware differential to per-bit LLRs internal/radio/tetra/process.go (softType5FromDiffs)
Soft descramble colour-code sign flips in the LLR domain internal/radio/framing/soft_tetra.go (DescrambleTetraSoft)
Soft TCH/S decode soft deinterleave → depuncture → Viterbi → CRC internal/radio/tetra/tch.go (DecodeTCHSSoft)
Composer fallback soft first, hard TCHSpeechFrames otherwise internal/voice/composer/tetra_voice.go (decodeTETRASpeech)
Soft AACH rescue soft RM(30,14) recovers a marginal usage marker internal/radio/tetra/traffic.go (usageOfSoft)

In this post

  • What hard decision costs — the ~70% figure and where it comes from.
  • The differential is the soft information — LLRs for free from the demod.
  • Carrying LLRs in lockstep — the stash bridge and its alignment contract.
  • The soft decode chain, step for step — DecodeTCHSSoft mirrors the hard chain.
  • Fallback, CRC gates, and the AACH bonus — where soft helps beyond speech.

What hard decision costs

Part 5 built the hard-decision TCH/S path: slice each burst’s BKN1+BKN2, descramble, deinterleave, depuncture, Viterbi, check the class-2 CRC. On a clean carrier it works. On a marginal same-carrier call — the voice riding the same 25 kHz carrier as the control channel, at the edge of the receiver’s budget — it failed roughly 70% of the call’s bursts, and the recordings came out short and garbled. The vocoder was fine; Part 7 proved that. The bursts were real; the training-sequence correlator found them. The losses happened inside the FEC.

The reason is the first decision the pipeline makes. The demodulator produces a complex differential per symbol, and the slicer quantizes it to one of four dibits. A symbol sitting dead-center in its decision region and a symbol grazing the boundary produce the same dibit — the slicer reports the verdict and destroys the confidence. A convolutional decoder is precisely the machine that can spend that confidence: a Viterbi search weighing each received bit by its reliability will happily overrule two shaky bits on the strength of twelve solid ones. Feeding it hard bits forces every bit to count equally, and the textbook cost of that is about 2 dB of coding gain — which, at the margin this call lived at, is the difference between 30% yield and a usable recording. The general theory — LLRs, why the gain concentrates exactly at the margin — lives in Weak-Signal Engineering Part 8; this post is the TETRA case that motivated it.

The differential is the soft information

The elegant part of doing this for π/4-DQPSK is that the soft information costs nothing to produce. The demod already forms s·conj(prev) for every symbol — that is the differential decode from Part 1. And in that complex number, the two on-air bits’ log-likelihood ratios are simply the imaginary and real components. The receiver exposes it behind one optional callback:

// internal/radio/tetra/receiver/receiver.go (shape) — Options
// SoftSink, when non-nil, receives the complex π/4-DQPSK differential
// (s·conj(last)) for each symbol, aligned 1:1 with the dibits emitted
// to DibitSink and carrying the same baseIdx. It is the soft
// information for soft-decision channel decoding (the two on-air bits'
// LLRs are Im and Re of the differential). Emitted just before the
// matching DibitSink call. nil ⇒ no soft emission, zero overhead.
SoftSink func(diffs []complex64, baseIdx int)

softType5FromDiffs (in process.go) does the conversion, taking a rotation parameter because the constellation can sit at any of four residual rotations — its hard-slice is defined to equal the hard dibit path exactly, so the two streams can never disagree about which bits, only about how much to trust them. The convention throughout framing/soft_tetra.go is LLR > 0 ⇒ bit 0, magnitude = reliability, and an exact 0.0 is an erasure — which is also what soft depuncturing inserts for the bits the puncturing pattern never transmitted. That is strictly more honest than the hard path, which has to guess a value for punctured positions.

Carrying LLRs in lockstep

The receiver’s DibitSink contract predates all of this, and half the callers (tests, hard-only paths) neither know nor care about soft data. So the soft stream rides a stash bridge instead of a changed signature: SoftSink fires just before the matching DibitSink call with the same baseIdx, the pipeline stashes the differentials, and the extractor picks them up on its next Process:

// internal/radio/tetra/traffic.go (shape) — the lockstep contract
func (te *TrafficExtractor) StashSoft(diffs []complex64, baseIdx int) {
    te.pendingSoft = diffs
    te.pendingSoftBase = baseIdx
}

// Process: append the stashed differentials ONLY when they match this dibit
// block (same base + length) AND softBuf is already in lockstep with buf;
// otherwise drop the soft path (reset to empty) rather than risk misalignment.
if te.pendingSoft != nil && te.pendingSoftBase == baseIdx &&
    len(te.pendingSoft) == len(dibits) && len(te.softBuf) == len(te.buf) {
    te.softBuf = append(te.softBuf, te.pendingSoft...)
} else {
    te.softBuf = te.softBuf[:0]
}

The invariant is all-or-nothing: softBuf is either exactly len(buf) or empty. Every trim that drops dibits from the rolling buffer drops the same count of differentials, and any mismatch anywhere collapses the soft path to empty for that stretch. That severity is deliberate. LLRs shifted by even one symbol are confidently wrong about every bit — a decoder fed misaligned soft data does worse than the hard path it was meant to improve. A burst whose soft span is not fully covered simply decodes hard-only; softFrame returns nil and nothing downstream notices. The same stash-bridge pattern carries the raw pre-differential symbols (StashSymbols / symBuf) for the trained equalizer we meet in Part 9 — Weak-Signal Engineering Part 9 generalizes the whole parallel-buffer design.

differential s·conj(prev) hard slice dibit, confidence lost softType5FromDiffs 2 LLRs per symbol hard Viterbi DecodeRCPCTetraMother soft Viterbi DecodeRCPCTetraMotherSoft class-2 CRC same gate for both the fork keeps confidence alive on the accent path — the ~2 dB the marginal call was missing
Hard and soft paths fork at the differential and rejoin at the class-2 CRC gate; only the soft path carries the demod's confidence into the Viterbi search.

The soft decode chain, step for step

DecodeTCHSSoft mirrors the hard DecodeTCHS from Part 5 step for step, in the LLR domain. Same 24×18 deinterleave permutation, same split into class-0 / class-1 / class-2 regions, same rate-8/12 and rate-8/18 depuncture geometry — just twinned functions operating on float32 reliabilities instead of bits:

// internal/radio/tetra/tch.go (shape) — DecodeTCHSSoft
type3 := tchDeinterleaveSoft(type5LLR[:tchType3Bits])
class0 := type3[:tchClass0Bits]
c1 := type3[tchClass0Bits : tchClass0Bits+tchClass1Coded]
c2 := type3[tchClass0Bits+tchClass1Coded:]

m1 := framing.DepunctureRCPCTetraSoft(c1, framing.RCPCTetraPeriod23,
    framing.RCPCTetraPuncture23, 3*tchClass1Bits)
m2 := framing.DepunctureRCPCTetraSoft(c2, framing.RCPCTetraPeriod818,
    framing.RCPCTetraPuncture818, 3*(tchClass2Bits+tchCRCBits+tchTailBits))
conv, metric := framing.DecodeRCPCTetraMotherSoft(append(m1, m2...), tchConvIn)
// …class-2 CRC check is HARD and identical to DecodeTCHS

Two details are worth pausing on. First, the uncoded class-0 bits have no FEC to spend reliability on, so hardSliceLLR just slices them — soft decision only pays where a decoder exists to weigh evidence. Second, the class-2 CRC check at the end is the hard check from Part 3 — the fixed parity-check matrix, not an LFSR — computed over the Viterbi’s hard output. Soft decision changes how hard the decoder fights for a burst; it does not change what counts as winning. On the reporter’s marginal capture that combination took the same bursts the hard path dropped and recovered most of them — and when Part 9’s equalizer later stacked on top, the soft path is the stream it multiplied.

How that principle shaped the Go code

  • Opt-in at every layer. SoftSink nil means zero overhead; no StashSoft means softBuf stays empty and the extractor is byte-identical to the pre-soft code. Every test that predates the feature still passes untouched.
  • Twinned functions, not flags. tchDeinterleaveSoft, DepunctureRCPCTetraSoft, DecodeRCPCTetraMotherSoft, DecodeTCHSSoft — each hard function has a soft twin with the same geometry constants, so the two chains cannot drift apart structurally.
  • The scrambler moves into the sign domain. Descrambling XORs a keystream bit; in LLRs that is a sign flip. DescrambleTetraSoft applies exactly the flips DescrambleTetra applies, keyed by the same extended colour code from Part 4.

Fallback, CRC gates, and the AACH bonus

The composer’s voice chain (decodeTETRASpeech in internal/voice/composer/tetra_voice.go) tries soft first and falls back:

// internal/voice/composer/tetra_voice.go (shape)
if softType5 != nil {
    frames = tetra.TCHSpeechFramesSoft(softType5)
} else {
    frames = tetra.TCHSpeechFrames(frame)
}

The onBurst callback signature carries both — frame []byte, softType5 []float32 — so a burst whose soft span got dropped by the lockstep guard degrades to the hard decode of that one burst, not a lost burst. And the soft buffer earned a second job while it was there: the AACH usage marker — the per-slot call identifier that demultiplexes concurrent same-carrier calls — rides a small RM(30,14) block that frequently fails hard decode under load. usageOfSoft re-decodes it from the same differentials, gated by aachSoftMaxDist = 6: the soft maximum-likelihood codeword must sit within 6 bits of the hard-sliced word, so a rescued marker is a genuinely marginal burst re-decided, never a low-confidence guess that could route another call’s speech into this recording. Once the LLRs are flowing, every marginal decode in the burst becomes cheaper to save.

Where this goes next

Soft decision recovered the bursts the noise was costing us — and exposed what noise wasn’t. The residual garble on the reporter’s concurrent-load captures had structure: a smeared constellation that no amount of per-bit confidence can fix, because the symbols themselves were dragged off their positions by the channel. Part 9 puts a blind equalizer between timing recovery and the differential decoder — and explains why the obvious way to do that corrupts every dibit, and the snapshot trick that doesn’t.

FAQ

Where do the LLRs actually come from — is there a separate estimator? No. The demod already computes the complex differential s·conj(prev) for every symbol; for π/4-DQPSK the two transmitted bits’ LLRs are its imaginary and real parts. softType5FromDiffs reshuffles and signs them per the rotation; nothing new is measured.

Why is the soft path allowed to silently fall back to hard? Because misaligned soft data is worse than none. The lockstep contract (softBuf exactly parallel or empty) means any chunk boundary hiccup degrades one burst to the hard decode instead of decoding it with shifted LLRs — which would be confidently wrong about every bit and raise the error rate.

Does soft decision ever pass a burst the hard CRC would reject? The gate is identical — the class-2 parity-check over the Viterbi’s hard output. Soft decision finds better codeword paths through the trellis, so more real bursts reach the gate intact; a random or foreign burst still passes only at the ~1/256 chance floor either way.

How much did it actually recover? The marginal same-carrier call that motivated it was losing ~70% of its bursts hard-only. Soft decision recovered the bulk of those, and it compounds with Part 9’s equalizer — the 410→778 CRC-valid figure across six captures is measured on the soft stream.

Why hard-slice the class-0 bits instead of keeping them soft? Class 0 is uncoded — there is no decoder downstream to spend the reliability. An LLR only buys something when a code constrains which bit patterns are possible; for uncoded bits the sign is all the information there is.

Series navigation

Part 8 of 14 · ← Part 7: Conformance — Bit-Identical Against the ETSI Reference · Next → Part 9: The Equalizer on the Voice Path