Two banks and a multiplier: vocoder.h
The user-facing chapter made three flat promises about
tap.vocoder~: a silent carrier is silence, gain is exactly linear, and a
silent modulator decays away at the follower rate. It could afford to,
because none of those is a tuning outcome — each one is a structural fact
about a very small graph. This appendix draws the graph, proves the facts,
and then walks the three numerical choices (band placement, filter type,
follower coefficient) that make the graph sound like a vocoder.
One honesty note up front. The original tap.vocoder~ source did not
survive the revival; vocoder.h is reconstructed from the reference
documentation — "a basic 24-band vocoder" with q and response_interval
attributes. The topology below is the classic channel vocoder that
documentation describes, and the tests pin its structural behavior; there is
no lost binary to bit-compare against, and this chapter never pretends
otherwise.
The graph: a bilinear form in 24 subbands
A channel vocoder is subband multiplication. Split both signals with the same filter bank, measure the modulator's level per band, scale each carrier band by that level, sum:
band i: m_i = B_i(modulator) the modulator through bandpass i
env_i ← follower(|m_i|) its envelope
c_i = B_i(carrier) the carrier through the identical bandpass
output: y = gain · Σᵢ c_i · env_i
That is bank::process() verbatim — the loop body computes m, rect,
m_env[i], c, and accumulates c * m_env[i], and the return line applies
m_gain once to the sum. Three contracts follow from the shape alone, and
tests/vocoder_test.cpp pins each one:
- Silent carrier ⇒ exactly silence. Every summand carries a factor
c_i. A biquad is linear with zero state at rest, so a zero carrier givesc_i ≡ 0for all i, and the sum is identically zero no matter what the modulator (and hence the envelopes) does. The test drives a 220 Hz modulator against a zero carrier for 8000 samples and requires peak < 10⁻¹², but the true bound is exact:0.0 * m_env[i]is 0.0. - Gain is exactly linear. The multiply
c_i · env_iis the only nonlinearity in the graph, and it is bilinear — linear in the carrier with the envelopes held fixed, linear in the envelopes with the carrier held fixed.m_gainsits outside all of it, a scalar on the finished sum, and nothing upstream reads it. Two banks fed identical inputs with gains 1 and 2 must differ by exactly a factor of 2, float for float; the test requires|yb − 2·ya| < 10⁻¹²across 8000 samples. - Silent modulator ⇒ output decays at the follower rate. With the
modulator silenced,
rect = 0and each envelope obeysenv ← m_env_coef · env— a geometric decay with the follower's time constant. The output is bounded byΣ|c_i|·env_i, so it decays with the envelopes even while the carrier keeps playing. The test warms the bank up, silences the modulator for one second at 48 kHz (≈ 50 time constants at the 20 ms default — a decay of e⁻⁵⁰), and requires the late output under 10⁻⁴ of the warmed level.
The fourth pinned property, determinism (two identical runs compare equal
with ==), is the repo-wide claim that the kernel is pure state-machine
arithmetic: no randomness, no time, no allocation in the audio path.
The bilinear form in 24 subbands — the graph shape the proofs read off.
Where the bands sit
Twenty-four bands span 50 Hz to 12 kHz, log-spaced. band_frequency(i)
computes:
f_i = k_fmin · (k_fmax / k_fmin)^(i / (k_bands − 1)) i = 0 … 23
= 50 · 240^(i/23)
so adjacent centres sit at a constant ratio of 240^(1/23) ≈ 1.269 — about
0.344 octave, a hair over four semitones, per band. Log spacing is the only
defensible choice for this machine, twice over: the ear judges musical width
by ratio, not by hertz, so equal-ratio bands devote equal perceptual width
to each channel; and speech puts its identity (formants, the envelope the
vocoder exists to capture) in the low kilohertz while its detail
(fricatives) rides above — a linear spacing would waste twenty bands above
6 kHz and cram every vowel into two. The span itself brackets speech: 50 Hz
is below any voice fundamental, 12 kHz is above any formant that matters,
and recalc_filters() clamps each centre at 0.45 · m_sr so the top bands
stay well below Nyquist at low sample rates rather than folding.
The filter: constant peak, unconditional stability
Each band is an RBJ Audio-EQ-Cookbook bandpass, the constant 0 dB-peak
variant, computed in recalc_filters():
w0 = 2π · fc / sr alpha = sin(w0) / (2·q) a0 = 1 + alpha
b0 = alpha / a0 a1 = (−2·cos w0) / a0
b1 = 0
b2 = −alpha / a0 a2 = (1 − alpha) / a0
"Constant 0 dB peak" is a normalization claim: the gain at the centre frequency is exactly 1, for any Q. It is worth proving, because the whole level architecture rests on it. Evaluate the transfer function at z = e^(jw0):
H(z) = alpha·(1 − z⁻²) / [(1 + alpha) − 2·cos w0 · z⁻¹ + (1 − alpha)·z⁻²]
denominator at z = e^(jw0):
[1 − 2·cos w0 · e^(−jw0) + e^(−2jw0)] + alpha·(1 − e^(−2jw0))
= e^(−jw0)·(e^(jw0) − 2·cos w0 + e^(−jw0)) + alpha·(1 − e^(−2jw0))
= e^(−jw0)·(2·cos w0 − 2·cos w0) + alpha·(1 − e^(−2jw0))
= alpha·(1 − e^(−2jw0)) = the numerator exactly
so H(e^(jw0)) = 1 identically. Why it matters here: env_i is supposed to
measure the signal's level in band i, and each carrier band is supposed to
be scaled by that measurement and nothing else. With the constant-peak
variant, changing q changes bandwidth only — the on-centre gain of all 48
filters stays pinned at unity, so the q knob narrows or overlaps the bands
without re-balancing the reconstructed spectrum or re-calibrating the
envelope levels. The cookbook's other bandpass (constant skirt gain) has
peak gain Q; with the default q = 20 that would be +26 dB per band, scaling
with the knob — every q move would also be a 24-band gain move.
Stability is likewise unconditional. A biquad is stable iff its
coefficients sit in the stability triangle, |a2| < 1 and |a1| < 1 + a2.
Here a2 = (1 − alpha)/(1 + alpha), which lies in (−1, 1) whenever
alpha > 0 — and alpha = sin(w0)/(2q) is positive for any q > 0 and any
0 < fc < Nyquist; the second condition, 2|cos w0|/(1 + alpha) <
1 + (1 − alpha)/(1 + alpha) = 2/(1 + alpha), reduces to |cos w0| < 1, true
on the same range. The code enforces the preconditions rather than
assuming them: q is floored at 0.001 and fc clamped to 0.45·sr, so no
attribute value and no sample rate can produce an unstable band. The
sections run as Direct Form I (biquad::process keeps x1, x2, y1, y2) —
at these moderate Qs and double precision, the plainest form is the
honest one.
The follower: one coefficient, symmetric by construction
Each band's envelope is a one-pole lowpass over the full-wave rectified band signal:
rect = |m_i|
env_i ← m_env_coef · env_i + (1 − m_env_coef) · rect
with the coefficient computed in recalc_envelope() from the
response_interval attribute:
tau = response_ms / 1000 (ms → seconds)
m_env_coef = exp(−1 / (tau · sr))
That is the exact one-sample step of a continuous first-order lag with time
constant τ: the discrete pole e^(−T/τ) with T = 1/sr. So the documented
"analysis period" is a time constant, precisely — after response_interval
milliseconds of silence an envelope has decayed to 1/e of its value, and
after a step up it has covered 1 − 1/e of the distance. Note what the code
does not have: separate attack and release. One coefficient serves both
directions, which is what the legacy surface documents (a single
response_interval) and is why the user chapter calls the knob "the
vocoder's attack and release." The 10⁻⁴ floor on response_ms keeps the
exponent finite; at the 20 ms default and 48 kHz, m_env_coef ≈ 0.99896.
Why time-domain, when the siblings went spectral
tap.nr~ and tap.spectra~ (next chapter) are STFT machines. The vocoder
deliberately is not, for three compounding reasons:
- Zero algorithmic latency. The spectral scaffold costs exactly one FFT frame of delay by construction; this graph's output at sample t depends only on inputs up to t. A vocoder is played live against its carrier — latency is a musical defect here in a way it is not for noise reduction.
- It is cheap. 48 biquads (5 multiplies + 4 adds each in DF I) plus 24 follower updates and 24 multiply-accumulates — on the order of three hundred flops per sample, no transform, no windowing, no frame buffers.
- It is faithful. The original
tap.vocoder~was a real time-domain external; the pfft~-hosted abstraction that wrapped it in some patches only added smoothing and gain around it. Rebuilding it as an FFT effect would have been reconstructing a different object. Sovocoder.hfollows thesvf.h/ladder.hidiom —prepare(samplerate)then per-sampleprocess()— not theconfigure(fftsize)scaffold of the spectral set.
The engineering ledger
prepare()recomputes everything. It callsrecalc_filters()(24 coefficient sets, each written into bothm_mod[i]andm_car[i]— the banks are identical by construction, one computation assigned twice) andrecalc_envelope().set_qre-runs only the filters,set_response_msonly the envelope coefficient,set_gainis a bare store — each setter pays for exactly what it moves, the small-scale version ofsvf.h's two-tier update.- Setters are allocation-free and audio-safe. All state is in fixed
std::arrays sized byk_bands; there is no allocation anywhere in the class, so the Min wrapper can forward attribute changes from the message thread while the perform loop runs. - The legacy surface is honored, with one documented fix.
qandresponse_intervalkeep their documented names, meanings, and defaults (20 and 20 ms). The original registered both attributes assymbol; the wrapper (tap.vocoder_tilde.cpp) registers them asnumber, which is what they actually are — a Q value and a millisecond time — and says so in its header.gainis a small, admitted addition for level staging, since a band-multiplied signal lands quieter than either input. - Both banks clear together.
clear()zeroes all 48 biquad states and the envelope array — the whole graph's memory, nothing else, so aclearmessage can never leave a stale envelope gating a fresh carrier. - What is deliberately absent: per-band gain trims, separate attack/release, a noise-driven "unvoiced" band — all classic vocoder extensions, all outside the documented surface being reconstructed. The reference page promised a basic 24-band vocoder; the file implements exactly that and stops.
Checkpoint
The vocoder is a bilinear form: two identical 24-band banks and one multiply per band. Everything the tests pin — silence in, silence out; exact gain linearity; follower-rate release — is a consequence of that shape, not of tuning. The numerics are three choices: log spacing (equal ratio per band, matched to hearing and to speech), the constant-peak RBJ bandpass (band level measures the signal, not the Q, and stability is a theorem with the clamps in place), and the exact one-pole coefficient e^(−1/(τ·sr)) (the documented period is an honest time constant, symmetric in both directions). Time-domain because latency, cost, and history all point the same way.