Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
vocoder.hpp File Reference

The classic channel vocoder — Dudley's analysis-synthesis speech remaker. More...

#include <pulp/signal/ballistics_filter.hpp>
#include <pulp/signal/dc_blocker.hpp>
#include <pulp/signal/osc/va.hpp>
#include <pulp/signal/rng.hpp>
#include <pulp/signal/svf.hpp>
#include <pulp/signal/tpt_filter.hpp>
#include <pulp/signal/units.hpp>
#include <pulp/signal/vca.hpp>
#include <algorithm>
#include <array>
#include <cmath>
#include <cstddef>
#include <cstdint>

Go to the source code of this file.

Classes

class  pulp::signal::VocoderT< SampleType >
 A classic channel vocoder: analysis bank → envelopes → matched synthesis bank. More...

Namespaces

namespace  pulp
namespace  pulp::signal

Typedefs

using pulp::signal::Vocoder = VocoderT<float>
using pulp::signal::Vocoder64 = VocoderT<double>

Enumerations

enum class  pulp::signal::VocoderCarrier : std::uint8_t { pulp::signal::external , pulp::signal::internal }
 Where the thing that speaks comes from. More...
enum class  pulp::signal::VocoderWave : std::uint8_t { pulp::signal::saw , pulp::signal::pulse }
 The internal carrier's shape. Both are polyBLEP-corrected. More...

Detailed Description

The classic channel vocoder — Dudley's analysis-synthesis speech remaker.

A bank of bandpass filters measures a modulator (usually a voice) as a coarse, slowly-varying spectral envelope; a matched bank imposes that envelope on a spectrally rich carrier, one gain per band. The carrier speaks. That is the whole idea, and it is Homer Dudley's: reconstruct speech from a buzz source, a hiss source, and a set of band levels.

Three things separate a vocoder that works from one that does not, and all three are here rather than in a post-process:

  • The two banks are matched. Analysis band k and synthesis band k share one centre frequency and one section Q, computed once. If they drift apart the reconstruction is a smeared, detuned version of the modulator's formants — the classic vocoder bug, and one that sounds like a tuning problem rather than a filter problem, which is why the acceptance suite measures both banks against the same shipped f_c[k].
  • Voiced and unvoiced excitation are different sources. Dudley's buzz and hiss. A vocoder with only a pitched carrier turns every "s", "t" and "f" into a pitched chirp, and speech stops being intelligible long before it stops being recognisable.
  • The band ballistics are per band, not global. A rectified sinusoid at f_c ripples at 2·f_c; a follower fast enough for the top of the bank re-imposes that ripple as amplitude modulation at the bottom of it. The floors below are expressed in cycles of each band's own centre, so one pair of user controls behaves correctly across the whole bank.

Lineage (documented behaviour, concepts and band counts only)

Roland VP-330 (10-band vocoder in an ensemble bed), Moog 16-Channel Vocoder / Bode 7702 (16 bands, analysis→synthesis cross-matrix — the ancestor of set_formant_shift_semitones), Korg DVP-1 (a vocoder with its own internal carrier, which is why CarrierSource::internal exists). No filter coefficients, envelope constants or code from any of them are used.

Composition — what this module does NOT own

  • The bandpass sections. SvfT in bandpass mode, two cascaded per band per bank. Note the normalisation: a TPT SVF's bandpass output has peak gain Q, not 1, and two identical sections at one centre peak together at Q², so each band is scaled by 1/Q_section². Every gain statement in this file assumes unity-peak bands, and the acceptance suite measures that peak rather than trusting the algebra — the first draft of this file normalised once and shipped bands peaking at 2.371.
  • The followers. BallisticsFilterT in peak mode, which rectifies internally. Its time argument is a 10→90 % time, not a time constant — its shipped coefficient map is 1 − exp(−2.2/(t·fs)), and 2.2 is ln 9 rounded. Anything in this file that says "attack" or "release" in milliseconds means that convention, and kTenToNinety reuses the same 2.2 so the two never disagree. Note that the spec describes the map as exp(−1/(τ·fs)), a time-CONSTANT convention; the shipped primitive does not do that, and the difference is a factor of 2.2 in every follower time.
  • The per-band gain law. VcaT with Response::linear, because a band envelope is already a magnitude; an exponential law would square a quantity that was already the answer. Its clamp of the control to [0, 1] is load-bearing here, not incidental — it is what makes the reconstruction bound in kWorstCaseGain a bound rather than a hope.
  • The internal carrier. osc::VaOscillator, polyBLEP-corrected.
  • Noise, DC blocking, the sibilance high-pass. Xorshift32, DcBlocker, TptFilterT.
  • The ensemble tail. Deliberately absent — see the note below.

What is deliberately NOT here: the ensemble tail

The VP-330's signature is a vocoded voice sitting in a wide detuned bed, and the spec is explicit that this class owns no chorus state: the catalog node wires this module's mono output into a ChorusEnsembleT and crossfades by its own ensemble_amt. So there is no set_ensemble_amt() here and no include of chorus_family.hpp. The composition is not untested for being absent — the acceptance suite builds the node's wiring explicitly and asserts it, so the node author has an executable reference rather than a paragraph.

Constants: published vs design parameter

Published and cited at the constant: none of the numbers here are, and that is the honest position. What is published is the mechanism — the buzz/hiss excitation split and slowly-varying band levels (Dudley 1939), zero-crossing rate and high-frequency energy as voicing cues plus pre-emphasis (Flanagan 1972), quasi-logarithmic critical-band spacing as the reason to place centres geometrically (Zwicker 1961), and the TPT SVF topology (Zavalishin). Every numeric value below is a design parameter with a default and a range, or a derived identity (kCascadeBWFactor, Q_band) that is algebra on the shipped spacing rather than a citation. The classic band counts are documented behaviour, not sourced coefficients.

Anti-aliasing policy (series law 4)

None required, and the reason is structural rather than fortunate. The signal path contains exactly two nonlinearities and neither reaches the output as broadband audio: the follower's rectifier feeds a one-pole lowpass, not the output, and the internal oscillator is band-limited by polyBLEP construction. The per-band multiply is a product of an audio signal with a control signal whose bandwidth is bounded by the follower — a ring-modulation whose sidebands sit within a few hundred Hz of the band they belong to, far from Nyquist. Nothing here would benefit from oversampling.

Latency (series law 5)

latency_samples() is 0. Every stage is minimum-phase IIR — TPT bandpass sections, one-pole followers, one-zero pre-emphasis, first-order high-passes. The banks impose frequency-dependent group delay, which is inherent to IIR filtering and is not a compensable bulk delay, so there is nothing to report and an impulse produces output on sample 0.

RT contract: prepare(sample_rate) computes coefficients and clears state; it allocates nothing, because every buffer is a fixed std::array sized at construction for kMaxBands and for the widest ZCR window at the highest supported rate. set_*, process and reset allocate nothing, take no locks and perform no I/O. set_band_count/set_band_range_hz recompute filter coefficients and follower floors — control-rate work, still allocation-free, and they change only the active loop bound, never the storage. Randomness is one Xorshift32 rewound by reset() to kNoiseSeed, and one sample is drawn per process() call regardless of carrier source, so a render is bit-identical per (params, input) and does not depend on which branch ran.