Part 10 of Voice Coding. Parts 3–8 got us from bits to synthesized PCM. That PCM is correct, but “correct” and “sounds right” are different claims. This post is the conditioning stack that closes the gap — and the line GopherTrunk draws between staying faithful to the transmission and making it pleasant to listen to.
TL;DR: MBE audio conditioning happens in two domains. In the parameter domain, §6.2 spectral enhancement boosts under-represented mid-band harmonics before synthesis (energy-preserving). In the PCM domain, a fixed chain runs per frame: DC block → optional tone tilt → optional voice enhancer → AGC with a
tanhsoft-limiter. Finally, per call, optional EBU R128 loudness normalization rewrites the finished WAV with a single linear gain. Everything past the DC block and AGC is opt-in — the default path is byte-faithful to the transmission.
Key takeaways
- Spectral enhancement is not post-processing — it runs on harmonic
amplitudes before synthesis (
EnhanceAmplitudes, TIA-102.BABA §6.2), with a per-harmonic weight clamped to[0.5, 1.2]and total energy preserved. - The per-frame PCM chain is DC block → tone tilt → enhancer → AGC, run
inside every decoder’s
applyOutput. The optional stages are nil pass-throughs by default, so faithful output is byte-identical. - The AGC is a fast-attack / slow-release peak tracker to
TargetPeak, with a per-frame output ceiling that was the fix for the “robotic / over-driven” field reports. - Loudness normalization is a separate per-call pass: EBU R128 / BS.1770 measure-then-apply-one-gain, pure gain, no compression — so within-call dynamics survive.
Cheat sheet
| Stage | File | Domain | Default |
|---|---|---|---|
| Spectral enhancement | mbe/enhance.go |
parameter (pre-synth) | on (spec) |
| DC block | mbe/dcblock.go |
PCM | on |
| Tone tilt (warm shelf) | mbe/tonetilt.go |
PCM | off (opt-in) |
| Voice enhancer (HPF/shelf/LPF/comp) | mbe/enhancer.go |
PCM | off (opt-in) |
| AGC + soft-limiter | mbe/agc.go |
PCM | on |
| Loudness normalize | voice/normalize.go |
whole WAV | off (opt-in) |
In this post
- Two domains — enhancement before synthesis, conditioning after.
- The PCM chain — DC block, tone tilt, enhancer, and their order.
- The AGC — envelope tracking, the soft-limiter, and one nasty bug.
- Loudness — why normalization is a separate per-call gain, not a stage.
Two domains of “enhancement”
The word “enhancement” is overloaded here, so let’s split it. The first kind is
part of the vocoder spec: after cross-frame amplitude recovery and the
log2→linear conversion, TIA-102.BABA §6.2 boosts harmonics the model
under-represents — typically mid-band harmonics whose energy the PRBA + HOC
quantizer averages into neighbours. This is EnhanceAmplitudes, and it runs on
the harmonic amplitudes M[l] before any samples exist:
// internal/voice/mbe/enhance.go (shape)
// Per-harmonic weight, then a total-energy-preserving rescale.
// if 8·l ≤ L: W_l = 1.0 (low band untouched)
// else: W_l = (0.96·num/den)^0.25 (clamped to [0.5, 1.2])
func EnhanceAmplitudes(p Params, M *[57]float64) { /* ... */ }
Two properties keep it safe to run unconditionally: low-frequency harmonics
(8·l ≤ L) are left alone, and after the per-harmonic multiply the whole frame
is rescaled so Σ M_l² (total power) is unchanged — enhancement redistributes
spectral energy, it doesn’t add loudness. The [0.5, 1.2] clamp stops a
near-pure-tone frame (where the closed form’s denominator approaches zero) from
producing a runaway weight.
The second kind is everything that happens to the finished PCM, which is where the rest of this post lives.
The PCM chain
Every MBE decoder runs the same applyOutput after synthesis, and the order is
load-bearing:
// internal/voice/ambe2/decoder.go (shape) — identical shape in imbe
func (d *Decoder) applyOutput(pcm []float64, out []int16, freezeEnvelope bool) {
d.dc.Process(pcm) // DC-removal high-pass (always on)
d.tone.Process(pcm) // optional warm shelf (nil = pass-through)
d.enhancer.Process(pcm) // optional voice enhancer (nil = pass-through)
d.agc.Apply(pcm, out, freezeEnvelope) // level + soft-limit → int16
}
DC block comes first because the synthesizer can leave a small DC / very-low
offset (the voiced cosine sum and the §6.4 overlap-add noise aren’t guaranteed
zero-mean per frame). Left in, that offset wastes AGC headroom and adds
inaudible rumble the limiter then has to absorb. It’s a one-pole high-pass with
a pole at z ≈ 0.99, continuous across frames:
// internal/voice/mbe/dcblock.go (shape)
y[n] = x[n] − x[n−1] + 0.99·y[n−1]
Tone tilt is an optional first-order high-shelf. GopherTrunk’s software
AMBE+2 decode measured ~1.5–2 dB brighter than the mbelib reference across
1.5–4 kHz (issue #644), which reads as a thin, harsh “digital” timbre. The
ambe2-dmr-warm decoder trades a little of that brightness back:
out = lp + g·(x − lp), a low-pass split with the high band scaled by
g = 10^(−shelfDb/20). It’s a tone preference, not a codec fix — the residual
synthetic character of low-bitrate resynthesis is intrinsic to software
vocoding.
The voice enhancer is the fuller opt-in chain, enabled per-recording. It
replicates what the louder rival decoders do: a ~250 Hz rumble high-pass, a
presence high-shelf (the same brightness trim), a ~3.4 kHz telephone-band
low-pass (OP25’s trick to kill out-of-band quantization buzz), and an optional
soft-knee compressor. Crucially, it does not add a gain stage — output
loudness is handled by raising the AGC’s TargetPeak, so the enhancer never
fights the limiter:
// internal/voice/mbe/enhancer.go (shape)
func DefaultEnhancerConfig() EnhancerConfig {
return EnhancerConfig{
Enabled: true, HPFHz: 250, LPFHz: 3400, ShelfHz: 1500, ShelfDB: 2.0,
AGCTarget: 22000, // louder than faithful 18000, still under the 26213 knee
Compress: CompressConfig{ThresholdDB: -18, Ratio: 2, AttackMs: 5, ReleaseMs: 80},
}
}
The AGC and its soft-limiter
The synthesizer’s per-frame output magnitude is stable within a frame but
swings wildly between frames depending on Tl, voicing, and the noise draw.
Without leveling, every frame would either clip or nearly vanish. The AGC is a
fast-attack / slow-release peak-envelope tracker that scales each frame toward
TargetPeak (default 18000, ~5.2 dB below full scale):
// internal/voice/mbe/agc.go (shape)
if peak < a.env { coef = cfg.Release } else { coef = cfg.Attack } // 0.02 vs 0.5
a.env += (peak - a.env) * coef
gain := cfg.TargetPeak / max(a.env, cfg.NoiseFloor)
// per-frame ceiling: never push THIS frame's peak past the soft-limiter knee
if ceil := softLimitKnee / peak; gain > ceil { gain = ceil }
Samples above the knee (0.80·32767 = 26213) are tanh-compressed toward — but
never onto — the int16 rail, replacing a hard clip whose sharp corners injected
the harmonic distortion that read as “robotic.”
The problem we hit here
The AGC has a MinGain of 0.05 for a reason field captures made painfully
clear. An early build set a gain floor too high (a floor of 10 forced ≥10×
amplification of already-loud frames straight into the limiter), and a
near-silent onset frame could seed a tiny envelope that then over-amplified the
next louder frame. Captures showed mean_gain ≈ 1e5, peak_preclip ≈ 8e7,
clip_pct ≈ 65% — the measurable signature of the “over-driven” reports. The
per-frame output ceiling (softLimitKnee / peak) is the one-sided clamp that
fixed it: it can only reduce gain, so ordinary frames and the fast-attack
inter-frame dynamics are untouched, but no frame can be shoved past the knee by
a stale envelope. Natural speech lands at a crest factor around 9.
Loudness: a separate per-call gain
Everything above runs per frame. Loudness normalization runs once per
call, after the WAV is finished, and it is deliberately not a chain stage. It
mirrors ffmpeg’s two-pass linear loudnorm: measure the whole call’s
integrated loudness (EBU R128 / BS.1770), compute one linear gain toward
TargetLUFS, back it off if the true peak would exceed TruePeakDBTP, and
rewrite the file atomically:
// internal/voice/normalize.go (shape)
gain, ok := loudness.NormalizeGain(fs, sampleRate, cfg.params())
if !ok { return nil } // silent / too short — leave the recording untouched
for i, v := range fs { samples[i] = clampInt16(v * gain * 32768.0) }
return rewriteWAV(path, samples, sampleRate)
Pure gain, no compression — so the within-call dynamics the AGC produced are
preserved, and a call is made consistently loud rather than dynamically
squashed. MaxBoostDB caps the gain so a near-silent call doesn’t amplify hiss
up to target. It’s off by default because it rewrites files; operators who
prioritise uniform playback loudness across a scanner feed turn it on.
Where this goes next
The audio is now decoded, conditioned, and (optionally) leveled to a target. Part 11 takes it out of the decoder: WAV writing, pure-Go MP3 encoding with a Xing/LAME header, Goertzel-based tone-out detection, and the optional DVSI hardware vocoder fallback.
FAQ
Is spectral enhancement the same as the voice enhancer?
No. Spectral enhancement (EnhanceAmplitudes, §6.2) is part of the vocoder spec
and runs on harmonic amplitudes before synthesis, preserving total energy. The
voice enhancer is an opt-in PCM chain (high-pass, shelf, low-pass, compressor)
that trades faithfulness for a cleaner, louder subjective sound.
Why is the default output not “enhanced”? Because the default path aims to be faithful to the transmission — byte-for-byte what the spec synthesis produces. The tone tilt, voice enhancer, and loudness normalization are all opt-in nil pass-throughs, so enabling none of them leaves output identical to the faithful decode.
What causes “robotic” or over-driven audio?
Historically, an AGC that over-amplified quiet frames and slammed the next loud
frame into a hard clip — measurable as a crest factor near 3–4 with high clip
percentage. The tanh soft-limiter and the per-frame output ceiling fixed it;
natural speech now sits around a crest factor of 9.
What does loudness normalization do to dynamics? Nothing within a call — it applies a single linear gain toward a LUFS target (with a true-peak backoff), so the call’s internal loud/quiet dynamics survive. It only makes different calls consistently loud relative to each other.
Series navigation
Part 10 of 12 · ← Part 9: The Composer · Next → Part 11: Recording & Encoding