Skip to content

Commit 9816424

Browse files
committed
More DSP
1 parent 08283e9 commit 9816424

14 files changed

Lines changed: 2136 additions & 665 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 12 additions & 5 deletions
Large diffs are not rendered by default.

‎docs/dsp/index.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ available in the build.
4848
- [Oscillators](oscillators.md) - `FourierSeries`, the alias-free
4949
`SyncSpectralResampler`, the `AdditiveOscillator` / `WavetableOscillator`
5050
synthesis backends, the `SyncOscillator` facade, shared `WaveformBank` frames,
51-
and `MorphingOscillator` / `ModulatedOscillator` for morphing, FM, PM and sync.
51+
`ModulatedOscillator` for morphing, FM, PM and sync, and
52+
`PrismSpectrum` for modulatable ridge, dispersion and exact-PWM shaping.
5253

5354
## Key building blocks
5455

‎docs/dsp/oscillators.md‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,107 @@ the same allocation-free generation path for other sources. Fill its high-rate b
235235
`getGenerationLatencyInSamples()` reports decimation-only latency, while the
236236
existing `getLatencyInSamples()` still describes the complete up/down path.
237237

238+
## Prism spectral shaping
239+
240+
`PrismSpectrum<CoeffType = double>` shapes one `FourierSeries` into another. It is a
241+
pure spectral transform with no DSP state and no transform behind it, so it composes
242+
in front of whichever synthesis backend you were already going to use.
243+
244+
Applied to harmonic `h`, writing `u = log2 (h)` and `c` for the harmonic's complex
245+
coefficient (`cosine + i sine`), the pipeline runs in this order:
246+
247+
| Stage | Operation | Control |
248+
|---|---|---|
249+
| Ridge | `gain = 0.15 + 0.85 * ridge^2`, `ridge = 0.5 + 0.5 * cos (2 pi (u / ridgeSpacing - color))` | `ridgeSpacing` in octaves, clamped to [0.25, 8]; `color` slides the comb and is periodic. |
250+
| Tilt | `gain *= 2^(-tilt * u)` | `tilt` in gain octaves per harmonic octave, clamped to +/-4; 0 is flat. The ridge is periodic in `u`, so it cannot express an overall slope. |
251+
| Odd/even | odd and even harmonics scaled against each other | `oddEven` clamped to [0, 1]; 0 keeps only odd, 1 only even, 0.5 is neutral. No transcendentals at all. |
252+
| Formant | `gain *= 2^(formant * exp (-((u - formantPosition) / formantWidth)^2))` | `formant` is a signed depth in gain octaves, clamped to +/-4, 0 is bypass; `formantPosition` is the center in harmonic octaves, clamped to [0, 12]. One movable resonance against the ridge's periodic comb. |
253+
| Squash | `\|c\|` becomes `\|c\|^squash`, phase kept | `squash` clamped to [0.1, 4]; 1 is bypass. |
254+
| Squeeze | `c` multiplied by `1 - d * cis (-2 pi h w)` | `squeeze` is a pulse width in periods, clamped to [0, 0.5]; the depth `d` opens from 0 to 1 over the first `squeezeFadeWidth`, so 0 is a true bypass. |
255+
| Dispersion + scatter | `c` multiplied by `cis ((dispersion - 0.5) * u^2 + scatter * angle[h])` | `dispersion` clamped to [0, 1], 0.5 is flat; `scatter` clamped to [0, 1], 0 is bypass. Both are rotations, so their angles add and one `sincos` serves both - scatter is free on top of dispersion. `angle[h]` is fixed per harmonic, so a shape stays a timbre instead of re-scattering every block. |
256+
| Normalize | whole series scaled so `sum \|c\|` matches the source's | - |
257+
258+
Every stage is a per-harmonic scale or rotation, so **no stage can produce a
259+
frequency the source did not already contain**. That is the whole antialiasing
260+
argument: shaping cannot alias, and whatever plays the result still bandlimits per
261+
pitch. It is also the rule any further stage must obey: `c[h]` may be multiplied by
262+
anything, but a harmonic's frequency is pinned at `h` times the fundamental and cannot
263+
be moved - stretched or inharmonic partials are not expressible in a periodic series at
264+
all, and need a different oscillator rather than another stage here. Squeeze deserves the emphasis because it looks like it should alias -
265+
pulse-width modulation normally does - but subtracting a phase-shifted copy of a
266+
bandlimited signal is still bandlimited, so this is exact PWM. At `w = 0.5` the factor
267+
is zero for even `h` and two for odd, the square-from-sawtooth identity.
268+
269+
The faded depth is not cosmetic. The raw factor tends to `i * h * 2 pi w` as the width
270+
falls, and renormalizing that leaves a differentiator rather than the source spectrum,
271+
so the stage has no bypass width - a knob stepping off zero would jump. Fading `d` in
272+
over `squeezeFadeWidth` is what makes zero continuous with its neighbourhood, and it
273+
costs one multiply. The factor stays a per-harmonic complex scale either way, so the
274+
antialiasing argument is untouched.
275+
276+
Normalization preserves `sum |c|` rather than the waveform's peak, and the two are not
277+
the same: `sum |c|` bounds a peak from well above (about three times over for a
278+
sawtooth), and how close the waveform comes to that bound depends on how aligned its
279+
harmonic phases are. `dispersion` and `scatter` control exactly that, so if constant
280+
output level matters, measure the shaped waveform's peak and rescale - once per patch
281+
alongside the shaping, not per voice.
282+
283+
Normalization is what makes the controls level-safe. The ridge gain, the companding
284+
and the pulse-width factor (whose magnitude reaches 2) all change level, and
285+
`sum |c|` is an upper bound on the waveform's peak, so preserving it guarantees the
286+
output can never be louder than the source's worst case. The sum runs over harmonics
287+
only; DC is not carried across and the destination's DC is always zero.
288+
289+
```cpp
290+
constexpr int numHarmonics = 128;
291+
292+
yup::PrismSpectrum<double> spectrum;
293+
spectrum.prepare (numHarmonics);
294+
295+
const auto source = yup::FourierSeries<double>::create (yup::Waveform::sawtooth, numHarmonics);
296+
yup::FourierSeries<double> shaped (numHarmonics);
297+
298+
yup::PrismSpectrum<double>::Shape shape;
299+
shape.ridgeSpacing = 1.5;
300+
shape.dispersion = 0.65;
301+
spectrum.process (source, shaped, shape, 0.3 /* color */);
302+
303+
// Either backend will do; this one trades an inverse FFT per update for cheap samples.
304+
yup::WavetableOscillator<float> oscillator;
305+
oscillator.prepare (48000.0, numHarmonics);
306+
oscillator.setSeries (shaped);
307+
oscillator.setFrequency (220.0);
308+
oscillator.render(); // crossfades into the new table
309+
```
310+
311+
### Modulating the shape
312+
313+
`prepare (maxHarmonics)` precomputes `log2 (h)` and `log2 (h)^2`, which deletes a
314+
`log2` per harmonic outright. What remains is a handful of transcendentals per
315+
harmonic and **no transform**, which is cheap enough to re-run once per audio block -
316+
so the shape controls are modulatable, not just settable.
317+
318+
Two rules make that affordable:
319+
320+
- **Shape once, not once per voice.** The shape belongs to a patch, not to a note. One
321+
`process()` per block feeding every voice costs the same at one voice as at sixteen;
322+
calling it inside each voice is what makes it expensive.
323+
- **Let the backend absorb the update rate.** `WavetableOscillator::render()`
324+
crossfades into the new table, so a per-block update sounds continuous, and nothing
325+
is rendered at all while the controls are still. Note the cost is one inverse FFT per
326+
*sounding table* per block, so it scales linearly with unison width as well as with
327+
polyphony: a few percent of a core at one table per voice, several times that at five.
328+
Where that matters, refresh a single-frame `WaveformBank` once per patch instead - it
329+
is a fixed cost in voices, which is exactly what `refreshFrames()` is for. `AdditiveOscillator` removes the transform entirely and takes new
330+
coefficients with zero latency, at the price of a multiply-accumulate per harmonic
331+
per sample.
332+
333+
Pre-rendering a sweep of `color` positions into a `WaveformBank` is the other obvious
334+
move, and it is a trap unless `color` is the only control you modulate: it buys
335+
audio-rate `color` but multiplies the cost of every *other* shape control by the number
336+
of frames times the number of bandwidth levels, which is precisely the work you were
337+
trying to avoid.
338+
238339
## Verification and performance
239340
240341
The tests include scalar spectral references, exact Nyquist boundaries, phasor

0 commit comments

Comments
 (0)