The sinusoidal bank: a tone built by summing sinusoids, one phase accumulator per partial, up to 128 of them. More...
#include <pulp/signal/denormal.hpp>#include <pulp/signal/additive_spectral_envelope.hpp>#include <pulp/signal/envelope.hpp>#include <pulp/signal/fast_math.hpp>#include <pulp/signal/osc/phase.hpp>#include <pulp/signal/rng.hpp>#include <pulp/signal/units.hpp>#include <algorithm>#include <array>#include <cmath>#include <cstdint>#include <type_traits>#include <vector>Go to the source code of this file.
Classes | |
| struct | pulp::signal::VoicePartial |
| Which abscissa a SpectralEnvelope's break-points are measured along. More... | |
| struct | pulp::signal::VoiceTable |
| A partial set. Fixed capacity; copying one is a memcpy, never an allocation. More... | |
| class | pulp::signal::AdditiveBankT< SampleType > |
| A sinusoidal-bank additive synthesizer. More... | |
Namespaces | |
| namespace | pulp |
| namespace | pulp::signal |
Typedefs | |
| using | pulp::signal::AdditiveBank = AdditiveBankT<float> |
| using | pulp::signal::AdditiveBank64 = AdditiveBankT<double> |
Functions | |
| VoiceTable | pulp::signal::make_organ_voice (int count=64) |
| The drawbar organ voice (§10a): nine tonewheel footages over a −12 dB/oct harmonic tail. | |
| VoiceTable | pulp::signal::make_bell_voice (int count=64) |
| The bell voice (§10b): a modal body whose partials each decay at their own rate. | |
The sinusoidal bank: a tone built by summing sinusoids, one phase accumulator per partial, up to 128 of them.
This is the most physically direct synthesis method there is — a spectrogram made audible. It earns its place in the catalog by covering two things subtractive and wavetable engines cannot:
Spectral Modeling Synthesis (Serra & Smith 1990) is an analysis/resynthesis system: it estimates partial tracks and a residual noise model FROM A RECORDING, then replays them. This engine ships the SYNTHESIS half only — it plays partial tables supplied by an author or a preset.
The reason is not squeamishness about difficulty. Analysis — peak picking, partial tracking across frames, birth/death heuristics, residual modelling — is an offline, non-real-time, framework-scale subsystem with its own latency and its own tuning surface. Putting it behind a real-time node's process() would mean either blocking the audio thread or inventing a second, hidden threading contract. It belongs in a tooling pipeline that emits voice tables this engine then plays. SMS is cited here as the lineage that explains WHY per-partial trajectories matter, not as something half-implemented.
The "plus stochastic" band of SMS — filtered noise for breath and attack chiff — is likewise absent. This engine is purely tonal. Compose an external noise source and LpgT for now.
Inverse-FFT synthesis (Rodet & Depalle 1992) is also out, and that one is a measured tradeoff rather than a scoping decision: it costs one window/hop of latency and per-frame phase-continuity bookkeeping, and only out-earns a direct oscillator bank above several hundred partials. At this engine's 128-partial ceiling the direct bank wins on both latency (0 versus a frame) and control granularity (sample-accurate frequency trajectories versus frame quantisation). See latency_samples().
Law 1 (gain-carrying nonlinearity) — N/A, provably. The signal path is a pure linear sum of sinusoids. There is no waveshaper, no filter, no feedback path, and therefore no loop gain to bound. The only gain question a sine bank raises is the CREST FACTOR of a coherent sum, and that is bounded by construction rather than by a limiter — see the normaliser below.
Law 4 (oversampling) — N/A, and for a reason worth stating rather than waving at. A single sinusoid has no harmonics. There is no waveshape nonlinearity anywhere in this engine to fold anything, so there is nothing for oversampling to catch. The ONLY way this engine can alias is a partial whose own frequency crosses Nyquist — a low fundamental with many harmonics, or a glide or an inharmonicity sweep that walks a partial upward. That is handled at the source by the per-partial Nyquist guard, which fades a partial out before its frequency reaches fs/2. Oversampling would not help with it: a partial above Nyquist is above Nyquist at any oversampling factor, it just folds somewhere else.
Law 5 (latency) — 0, tested. Direct time-domain oscillators. Nothing is buffered, so with the attack at zero an impulse-phase render is non-zero at sample 0.
Law 7 (scale invariance) — the interesting one, stated precisely because the usual phrasing is wrong. Partial amplitudes are sampled from a dimensionless spectral-envelope SHAPE rather than stored as per-pitch amplitude tables. That is the law's real content: never interpolate independently-fitted per-partial amplitude sets across pitch, because near-identical curves fit into different parameter basins and the interpolation walks between them through timbres neither endpoint contains.
But "the spectral envelope makes the timbre pitch-independent" is only true under a condition, and this header states it rather than repeating the slogan. See SpectralDomain below.
With every partial in phase — which is not a statistical worry but a CONSTRUCTIBLE state, reached by storing φ̃ₚ = 0.25 for all p and retriggering — the instantaneous sum reaches Σ|aₚ|. There is no feedback and no limiter, so the bound has to come from a static normaliser applied at control rate:
where the sum runs over the current, post-envelope, post-morph, post-guard amplitudes. Then by the triangle inequality
since every per-partial envelope is in [0, 1]. This is an equality when the phases align, so the bound is TIGHT, not conservative — and the suite asserts both halves.
Two details that make the bound survive real use:
Phase is a timbre control, not just a determinism detail. Coherent phases give a percussive click transient (high crest); scattered phases give a softer, airier attack at identical partial amplitudes. That is why the retrigger policy is an enum on the public surface.
osc::PhaseAccumulator, one instance per partial, composed rather than reimplemented. Its own doc block explains the arithmetic; the reason it matters HERE is that a bank holds notes for a long time. At 20 Hz and 96 kHz the increment is ~2e-4, where float carries ~1e-7 of relative error per accumulation — which integrates into audible pitch drift over a held pad, and into partials sliding out of their harmonic relationship with each other, which is worse than either drifting alone.
The default is std::sin(2π·φ) evaluated in double — the catalog sine, not a substitute for it. It is bit-identical to osc::VaOscillator at the same phase, which the suite asserts. Float banks may explicitly select FastTrigProfile::realtime_precise: on supported Apple arm64/Clang targets four bounded-cycle degree-9 evaluations run together while phase accumulation, wrap events, gain/frequency ramps, and envelope state remain in double. The profile has its own two-float-LSB bank-output and −100 dB THD gates. Other targets retain the same scalar approximation semantics but do not claim an acceleration; query trig_profile_has_vector_path() before presenting the choice as a performance feature. AdditiveBank64 always retains reference sine semantics.
| Name | Default | Range | Why |
|---|---|---|---|
| kMaxPartials | 128 | 1 .. 256 | hard array bound; prepare() never exceeds it |
| kPartialCountDefault | 64 | 1 .. 128 | covers both shipped voices with headroom |
| kFundamentalDefaultHz | 220 | 20 .. 8000 | base pitch |
| kInharmonicityDefault | 0.0 | 0 .. 0.05 | 0 = pure harmonic; see the B note below |
| kSpectralTiltDefaultDbOct | −6 | −24 .. +6 | global brightness |
| kMasterGainDefaultDb | −6 | −60 .. +6 | trim; the default leaves crest headroom |
| kAttackDefaultMs | 5 | 0 .. 2000 | shared onset |
| kReleaseDefaultMs | 400 | 1 .. 20000 | key-off fall |
| kDetuneDefaultCents | 0 | 0 .. 50 | doublet spread; 0 disables the pair |
| kAmpUpdateCadenceSamples | 32 | 8 .. 128 | control-rate cadence, ramped between |
| kNyquistGuardBandHz | 1000 | 200 .. 4000 | taper width below fs/2 |
| kDoubletJitterSpread | 0.5 | 0 .. 1 | per-mode variation in doublet beat rate |
| kEnvelopeCurve | 0.5 | 0 .. 1 | shared onset/release curvature |
| SpectralEnvelope::kMaxBreakpoints | 64 | 2 .. 64 | 16 is the documented working size |
The inharmonicity coefficient B. The LAW is published — Fletcher, Blackham & Stratton 1962 — and so is the measured band across the piano compass, roughly B ≈ 8e-5 to 2e-2, minimum in the tenor and rising toward both extremes. The shipped reference points (piano tenor 0.0002, piano mid 0.0008, upper treble 0.004, electric-piano tine 0.02) are design parameters chosen INSIDE that published band. The parameter's range extends to 0.05, which is an honest gap: no citable literature was found supporting B > 0.02, and that headroom exists only for deliberately hyper-metallic timbres beyond any measured real string or bar. No shipped voice uses it.