The single-sideband (Bode/Moog) frequency shifter: a Hilbert phase-difference network, an exact quadrature carrier, an SSB combine, and a barberpole feedback loop. More...
#include <pulp/signal/dc_blocker.hpp>#include <pulp/signal/delay_line.hpp>#include <pulp/signal/denormal.hpp>#include <pulp/signal/osc/phase.hpp>#include <pulp/signal/smoothed_value.hpp>#include <pulp/signal/units.hpp>#include <algorithm>#include <array>#include <cmath>#include <cstddef>#include <cstdint>Go to the source code of this file.
Classes | |
| class | pulp::signal::HilbertQuadratureNetworkT< SampleType > |
| A pair of allpass cascades whose outputs stand a quarter cycle apart across nearly the whole band — the analytic-signal splitter an SSB shifter needs. More... | |
| struct | pulp::signal::HilbertQuadratureNetworkT< SampleType >::Outputs |
| class | pulp::signal::SsbFrequencyShifterT< SampleType > |
| The single-sideband frequency shifter. More... | |
Namespaces | |
| namespace | pulp |
| namespace | pulp::signal |
Typedefs | |
| using | pulp::signal::HilbertQuadratureNetwork = HilbertQuadratureNetworkT<double> |
| using | pulp::signal::SsbFrequencyShifter = SsbFrequencyShifterT<float> |
| using | pulp::signal::SsbFrequencyShifter64 = SsbFrequencyShifterT<double> |
Enumerations | |
| enum class | pulp::signal::FrequencyShiftMode : std::uint8_t { pulp::signal::up , pulp::signal::down , pulp::signal::dual_mono , pulp::signal::stereo_split } |
| Which sideband a SsbFrequencyShifterT keeps, and how it spreads it. More... | |
The single-sideband (Bode/Moog) frequency shifter: a Hilbert phase-difference network, an exact quadrature carrier, an SSB combine, and a barberpole feedback loop.
A frequency shifter ADDS a constant number of hertz to every component of a signal — f → f + Δf — where a pitch shifter MULTIPLIES by a ratio. That is the entire point of the effect and it is asserted, not asserted-about: a 440 Hz fundamental with an 880 Hz octave partial, shifted by +5 Hz, becomes 445 and 885 Hz, whose ratio is 1.989 rather than 2. The harmonic series is destroyed on purpose; that inharmonicity is the sound, and it is why a bank of tuned resonators becomes a cymbal under a few tens of hertz of shift.
frequency_shifter.hpp already carried a 159-line prototype of this idea, and this file does not delete it, because drum/cymbal.hpp composes its FrequencyShifterT by name. What this file does instead is take ownership of the math: the prototype's private Hilbert network and its private coefficient table are gone, and it now composes HilbertQuadratureNetworkT and the combine from here. There is exactly one quadrature network and one coefficient table in the tree, and it is this one. Two things were wrong in the prototype's own copy, both of which the acceptance suite here now covers:
The DC blocker sits between the feedback sum and the network so ONE instance protects both paths: a DC component reaching the combine would be "shifted from 0 Hz" and emerge as a bare tone at the shift frequency, and anything the recirculation path introduces gets swept up on the next pass rather than accumulating. The dry/wet mix sits after the feedback tap, so turning mix down attenuates the barberpole without disabling it.
The carrier is exact. cos φ and sin φ are evaluated from the SAME accumulated phase every sample. A recursive resonator is forbidden here: its slow amplitude and phase drift is inaudible in a tremolo and fatal in a carrier, because every microradian of carrier phase error subtracts directly from image rejection. This is the discipline LfoT::next_quadrature documents, and this module would compose it literally except that LfoT clamps its rate to LfoT::kMaxRateHz = 200 Hz — two orders of magnitude below this module's ±5 kHz. So the accumulator underneath it (osc::PhaseAccumulator) is composed directly and the exactness contract is kept identically: advance, then evaluate both trigonometric functions at the advanced phase.
Image rejection is a measured property of the shipped table, not a claim. For an exactly-allpass quadrature pair with phase error θ from the ideal quarter cycle, the retained sideband has amplitude |cos(θ/2)| and the unwanted one |sin(θ/2)|, so rejection is −20·log10|tan(θ/2)|: 40 dB needs θ ≤ 0.0200 rad, 60 dB needs θ ≤ 0.0020 rad. The shipped four-section table holds θ ≤ 0.0200 rad — hence kImageRejectDb = 40 dB — over the NORMALISED band [kBandEdgeNormalized, 0.5 − kBandEdgeNormalized] of the sample rate; the measured floor across that band is 44.2 dB, at f/fs = 0.00128.
The band is normalised, and that is not a dodge. A fixed allpass table is a function of ω = 2π f / fs alone, so its band edges are fixed FRACTIONS of the sample rate and its edge in hertz necessarily rises with fs: 20.3 Hz at 44.1 kHz, 22.1 Hz at 48 kHz, 44.2 Hz at 96 kHz, 88.3 Hz at 192 kHz. There is no coefficient set that pins an absolute 20 Hz edge at every rate, and quoting one would be quoting a number true only at the rate it was measured at. Below the edge the rejection degrades gracefully rather than failing — 36.9 dB at 20 Hz / 48 kHz — and above the upper edge it is the mirror of the same curve.
Small-signal gain and the loop bound (series laws 1 and 8). Every element in the feedback loop is either exactly allpass (both Hilbert branches, whose magnitude is 1 identically, not approximately), a convex interpolation (the delay line), or the combine, whose retained-sideband gain |cos(θ/2)| is bounded ABOVE by 1. The single element that can exceed unity is the DC blocker, whose supremum is 2/(1 + p) at Nyquist — 1.00033 at the shipped corner and sample rate. kGshiftBudget = 1.02 is the headroom the registry pays for that, so per-pass loop gain is at most kMaxFeedback · kGshiftBudget = 0.918 and the steady-state envelope is 1/(1 − 0.918) ≈ 12.2×. The acceptance suite measures the actual retained gain and the actual loop-impulse peak against that envelope rather than trusting the arithmetic.
Series law 4 asks every module to state one. Frequency shifting is a LINEAR time-varying operation: it generates no harmonics, so there is nothing to oversample. Content shifted past fs/2 folds back, and content shifted below 0 Hz reflects to positive frequency with its sideband inverted — but that is the shifted spectrum itself crossing a band edge, not a distortion product, and oversampling would only move the edge, not remove it. The Bode and Moog hardware did the same thing. It is documented, bounded by kMaxShiftHz, and left alone.
There is no randomness anywhere in this module — no generator to seed, no dither, no noise floor. A render from reset() is bit-identical for identical (parameters, input), per series law 2.
RT contract: prepare(sample_rate) sizes the feedback delay lines and MAY allocate. set_*, process(), process_stereo() and reset() never allocate, never lock, never perform I/O, and are safe per sample on the audio thread. State is POD; a zero-initialised instance is a valid silent one at shift = 0, feedback = 0, fully wet. process() costs eight second-order allpass sections at two multiplies each, one sin/cos pair, and one interpolated delay read; process_stereo() costs a second set of sections and delay read but reuses the one carrier.
References: H. Bode and R. A. Moog, "A High-Accuracy Frequency Shifter for Professional Audio Applications", JAES 20(6), pp. 453–458, 1972 (the professional SSB shifter concept). D. K. Weaver, "A Third Method of Generation and Detection of Single-Sideband Signals", Proc. IRE 44(12), 1956 (the low-IF alternative, cited for lineage; not built — the Hartley/Hilbert path needs one wide-band network and no baseband filter bank). R. Ansari, "IIR Discrete-Time Hilbert Transformers", IEEE Trans. ASSP 35(8), pp. 1116–1119, 1987 (the polyphase-halfband → IIR Hilbert design procedure). O. Niemitalo, "Hilbert transform", yehar.com (the public two-cascade eight-multiply allpass form and the differential-evolution minimax coefficient set this table follows).