Part 3 of Voice Coding. Part 2 gave us the MBE parameter set — a pitch, a per-harmonic voicing decision, and spectral residuals. This post spends those parameters: it grows 160 samples of 8 kHz speech from them, which is the moment a list of numbers becomes a voice.
TL;DR: Synthesis runs
mbe.Paramsthrough four stages on a per-callSynthState. First it recovers the real harmonic log-amplitudes from the transmitted residualTlby predicting across frames (PredictLog2Ml). Then it splits the harmonics: voiced bands become summed sinusoids with phase carried coherently across frame boundaries (SynthVoiced), unvoiced bands become white noise FFT’d, shaped to the harmonic envelope, and IFFT’d back (SynthUnvoicedOverlapAdd). A power-complementary window overlap-adds the noise across frames so boundaries don’t click. The two contributions sum into one buffer.
Key takeaways
- The transmitted
Tlis a residual;PredictLog2Mlinterpolates the previous frame’s envelope to a pitch-scaled prediction, addsTl, and removes the prediction’s DC bias — that’s howTlbecomes reallog2(Ml). - Voiced harmonics are summed cosines with a linear amplitude tilt and a quadratic phase term across the 160 samples, so pitch glides and fade in/out are click-free.
- Unvoiced bands are synthesized in the frequency domain: noise → FFT → zero the
voiced bins, scale the unvoiced bins by
Ml→ IFFT. - Frame boundaries are healed two ways: coherent phase memory for voiced, overlap-add with a power-complementary window for unvoiced.
Cheat sheet
| Stage | Function (internal/voice/mbe/…) |
Role |
|---|---|---|
| Amplitude recovery | PredictLog2Ml (synth.go) |
Tl + prev-frame prediction → log2(Ml) |
| State roll-forward | UpdateLog2Ml / UpdateVoicedState |
store this frame’s pitch/amp/phase for the next |
| Voiced excitation | SynthVoiced (synth_voiced.go) |
summed sinusoids, phase-coherent across frames |
| Unvoiced excitation | SynthUnvoicedOverlapAdd (synth_unvoiced.go) |
noise → FFT → shape → IFFT → overlap-add |
| Spectrum shaping | ShapeUnvoicedSpectrum |
zero voiced/out-of-range bins, scale unvoiced by Ml |
| Inter-frame memory | SynthState (synth.go) |
prev ω₀, L, log2Ml, Ml, phase, unvoiced tail |
In this post
- From residual to amplitude — the cross-frame prediction, and the female-voice bug it fixed.
- Voiced synthesis — summed sinusoids with amplitude tilt and quadratic phase.
- Unvoiced synthesis — noise in the frequency domain, shaped to the envelope.
- Healing the seams — coherent phase and the overlap-add window.
From residual to amplitude
Part 2 left Tl[1..L] as a residual — the difference between each harmonic’s
log-amplitude and a prediction. The first synthesis stage reconstitutes the real
amplitudes. PredictLog2Ml implements TIA-102.BABA §6.1 equations 75–77:
// internal/voice/mbe/synth.go (shape)
func PredictLog2Ml(s *SynthState, p Params, dst *[57]float64) {
if p.Silent || p.L == 0 { return }
L := p.L
var pred [57]float64
if s.PrevL > 0 && s.PrevW0 > 0 {
gain := predictionGain(L) // pitch-dependent ρ
ratio := p.W0 / s.PrevW0
for l := 1; l <= L; l++ {
pos := float64(l) * ratio // where this harmonic sat last frame
// … clamp to [1, PrevL], linearly interpolate PrevLog2Ml at pos …
pred[l] = gain * interp(s.PrevLog2Ml, pos)
}
}
var ave float64
for l := 1; l <= L; l++ { ave += pred[l] }
ave /= float64(L)
for l := 1; l <= L; l++ { dst[l] = pred[l] + p.Tl[l] - ave } // eq. 77
}
The idea: a harmonic at index l this frame sat near index l · ω₀_curr/ω₀_prev
last frame (pitch changed, so the harmonics moved). Interpolate the previous
frame’s log2(Ml) at that position, scale it by a prediction gain ρ, average the
prediction across the frame, and combine prediction + Tl − average so the
residual carries the only intentional offset. On the very first frame there’s no
history, so the prediction term is zero and log2(Ml)[l] = Tl[l].
The problem we hit: one prediction gain for every pitch
That predictionGain(L) call hides a real bug fix. The prediction gain ρ controls
how much the previous frame’s envelope leaks into this one. GopherTrunk originally
hardcoded ρ = 0.65 for every frame. That’s fine for a low-pitched voice with
many, closely-spaced harmonics — the envelope really is stationary frame to frame.
But a high-pitched voice has few, widely-spaced harmonics, and a strong
prediction over-smears the previous (differently-spaced) envelope onto the current
one. The audible result was poor female-voice intelligibility compared to OP25 and
Trunk Recorder.
The fix is to make ρ pitch-dependent, matching the reference decoders:
// internal/voice/mbe/synth.go
func predictionGain(L int) float64 {
switch {
case L <= 15: return 0.4 // high pitch (e.g. female voices)
case L <= 24: return 0.03*float64(L) - 0.05
default: return 0.7 // low pitch
}
}
Low-L (high-pitch) frames now get a weak ρ = 0.4, so the current frame’s own Tl
dominates and the mismatched previous envelope doesn’t wash it out. It’s a
three-line switch, but it’s the difference between a codec that sounds broken on
half the population and one that doesn’t.
Voiced synthesis: summed sinusoids
Once log2(Ml) is linearized to Ml (a separate AmplitudesFromLog2Ml step),
the harmonics split on Vl[l]. Voiced harmonics go to SynthVoiced, which sums
one cosine per harmonic across the 160-sample frame — TIA-102.BABA §6.3:
The inner loop carries two subtleties that keep it click-free across frame boundaries:
// internal/voice/mbe/synth_voiced.go (shape) — per voiced harmonic l
a := lf * prevW0 // linear phase coeff
b := lf * (p.W0 - prevW0) * invDoubleN // quadratic: integrates the ω₀ glide
dAmp := currAmp - prevAmp
for n := 0; n < N; n++ { // N = 160
amp := prevAmp + dAmp*(float64(n)*invN) // amplitude tilt across frame
phase := thetaBase + a*float64(n) + b*float64(n)*float64(n)
dst[n] += amp * math.Cos(phase)
}
- Amplitude tilt. The harmonic fades linearly from its previous-frame amplitude to its current-frame amplitude across the 160 samples. A harmonic that was unvoiced last frame starts at zero and fades in; one going unvoiced this frame fades out to zero. No amplitude step, no click.
- Quadratic phase. The phase is
θ₀ + a·n + b·n². Then²term integrates the linear frequency glide fromω₀_prevtoω₀_curr, so a rising pitch sweeps smoothly instead of jumping at the frame edge.thetaBasecomes fromSynthState.PrevPhase[l]— the phase where this harmonic left off last frame — which is what “phase-coherent” means. After the frame,UpdateVoicedStateadvances that phase memory by the closed-form average-frequency increment so the next frame picks up exactly where this one ended.
There’s a subtlety even here: fully phase-coherent synthesis of every harmonic
sounds buzzy and robotic, because re-aligning all harmonics once per pitch period
radiates an impulse train. The IMBE path therefore uses SynthVoicedDispersed,
which perturbs the upper harmonics’ synthesis phase by a bounded random offset
(scaled by the unvoiced fraction) — applied to this frame’s excitation only, never
folded into the coherent memory. That’s the difference between “de-buzzed” and “a
random walk into noise.”
Unvoiced synthesis: noise in the frequency domain
Unvoiced bands can’t be sinusoids — they’re hiss. §6.4 synthesizes them in the frequency domain:
ShapeUnvoicedSpectrum does the classification, snapping each FFT bin to its
nearest harmonic centre l = round(2π·k / (N·ω₀)) and either zeroing it (voiced
harmonic, or outside [1..L]) or scaling it by Ml[l] (unvoiced harmonic). It’s
careful to scale the (k, N−k) conjugate pair by the same real value so the IFFT
stays real-valued. The noise itself is passed in rather than pulled from a source
inside the function — a deliberate choice so unit tests can feed deterministic
noise and pin the output exactly.
Healing the seams: the overlap-add window
Consecutive frames carry independent noise blocks, so if you just butted each
frame’s 160-sample IFFT against the next you’d get a click at every boundary. §6.4
fixes this with overlap-add: the FFT is 256 points (not 160), and each frame’s
windowed IFFT extends 96 samples into the next frame’s output, where it’s summed.
SynthState.PrevUnvoicedTail[96] carries that overlap.
The window choice is subtle enough to have its own regression test. A naive
periodic Hann at the 160-sample hop makes the noise-power envelope ripple ~7 dB,
which leaks through as a 50 Hz buzzy tremolo on fricatives — one of the
artifacts chased in the issue #644 “machine voice” report. The fix is a
power-complementary (tapered-cosine) window: a flat unity top over the
non-overlapping centre and a quarter-wave sine taper of length 96 on each edge.
With a sine taper, w[n]² + w[95−n]² = 1, so the summed noise power is exactly
flat across the frame (verified to <1e-9) while the edges still decay to zero to
suppress boundary clicks — and the flat top passes more aspiration energy than
the Hann did.
The two contributions — voiced sinusoids and unvoiced noise band — accumulate into
the same dst buffer (both functions add rather than overwrite), and that sum
is the frame’s 160 samples of speech.
How the state design shaped the Go
The through-line here is SynthState: prev ω₀, prev L, prev log2Ml, prev Ml,
prev phase, prev unvoiced tail. Every stateful stage reads it and a matching
Update…/roll-forward writes it, and the whole thing is owned per call by the
decoder — never shared. That’s the same non-shared-state discipline the registry
enforced in Part 1, applied one level down: two parallel calls each have their own
SynthState, so their phase memories can’t collide. Reset() (a plain
*s = SynthState{}) wipes it on a stream re-sync or a silence frame, and the zero
value is the valid “fresh stream, no previous frame” state — which is why the
first-frame prediction naturally collapses to Tl.
Where this goes next
The synthesizer is codec-blind — it only ever sees mbe.Params. The next three
parts fill that struct in for IMBE.
Part 4
unpacks the 4.4 kbps IMBE frame: the b_0 pitch codeword, the PRBA gain blocks,
the DCT spectral coefficients, and the scrambler that whitens the whole thing.
Parts 5–6 then handle the FEC and de-interleave that get clean bits to unpack, and
the acquisition squelch that stops the synthesizer voicing garbage during symbol
lock.
FAQ
Why does MBE synthesize voiced and unvoiced bands with completely different methods? Because they’re physically different sound sources. Voiced bands are periodic (pitched buzz), best rebuilt as summed sinusoids at the pitch harmonics; unvoiced bands are aperiodic (hiss), best rebuilt as shaped noise. Using one method for both would make vowels noisy or fricatives buzzy.
What is cross-frame prediction and why is it needed?
The codec transmits each harmonic’s amplitude as a residual from a prediction
based on the previous frame, to save bits. PredictLog2Ml rebuilds the prediction
— interpolating the previous envelope to the new pitch — and adds the residual
back, recovering the real amplitude.
Why did female voices sound worse before the fix? A single hardcoded prediction gain (0.65) over-smeared the previous frame’s widely-spaced envelope onto high-pitch frames. Making the gain pitch-dependent (0.4 for few-harmonic, high-pitch frames) let the current frame’s own data dominate, matching OP25 and Trunk Recorder.
Why 256-point FFT for a 160-sample frame? The extra 96 samples are the overlap-add region. Each frame’s windowed noise extends into the next frame’s output and is summed there, so independent noise blocks join without a click at the boundary.
Why not a plain Hann window on the unvoiced band? A periodic Hann at the 160-sample hop isn’t power-complementary — the summed noise power ripples ~7 dB, audible as a 50 Hz tremolo on fricatives. A tapered-cosine window with a flat top and sine edges keeps the summed power exactly flat while still suppressing boundary clicks.
Series navigation
Part 3 of 12 · ← Part 2: The MBE Model · Next → Part 4: IMBE Decode