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
SoftSinkemits the complex π/4-DQPSK differentials·conj(prev)per symbol, theTrafficExtractorcarries it in strict lockstep with the dibits (StashSoft,softBuf),softType5FromDiffsturns each differential into two per-bit LLRs,framing.DescrambleTetraSoftapplies the colour-code sign flips, andtetra.DecodeTCHSSoftruns soft depuncture + soft Viterbi (framing.DecodeRCPCTetraMotherSoft) to the same class-2 CRC gate. The composer tries soft first and falls back to the hardTCHSpeechFrameswhen 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.
softBufis 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.
TCHSpeechFramesSoftreturns 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 —
DecodeTCHSSoftmirrors 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.
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.
SoftSinknil means zero overhead; noStashSoftmeanssoftBufstays 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.
DescrambleTetraSoftapplies exactly the flipsDescrambleTetraapplies, 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