From 95b73aa76e689454c4101028f87f62200ea1ec6a Mon Sep 17 00:00:00 2001 From: Misha Nasledov Date: Sun, 4 Oct 2026 11:05:31 -0700 Subject: [PATCH 1/4] osc: blep, a saw, pulse and triangle that do not alias PolyBLEP corrections at each edge, the triangle a leaky integral of the band-limited pulse. On a C7 saw at 44.1 kHz the folded energy is -30.7 dB against the harmonics, where osc::simple's is -12.7 dB. A phase arg sets where a voice's cycle starts, so unison copies do not begin with their edges together. --- docs/NODES.md | 14 +++ plugins/CMakeLists.txt | 2 +- plugins/osc/blep.cpp | 207 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 plugins/osc/blep.cpp diff --git a/docs/NODES.md b/docs/NODES.md index 9829a9f3..1cbb57e7 100644 --- a/docs/NODES.md +++ b/docs/NODES.md @@ -944,6 +944,20 @@ Band-pass oscillator | `out` | out | The wave | | -1 to 1 | full scale | | | `last` | state | | | | | | +### osc::blep + +Band-limited oscillator (PolyBLEP saw, pulse, triangle) + +| Arg | Dir | Description | Default | Range | Units | Values | +|---|---|---|---|---|---|---| +| `freq` | in | Frequency | | | Hz | | +| `waveform` | in | Which wave | | | | 0 = Sawtooth, 1 = Pulse, 2 = Triangle | +| `pw` | in | Pulse width: how much of the cycle is high | 0.5 | 0 to 1 | | | +| `phase` | in | Where the cycle starts, read on the voice's first sample | | 0 to 1 | | | +| `out` | out | The wave | | -1 to 1 | full scale | | +| `sync` | out | 1 on the sample the cycle wraps, 0 otherwise | | 0 to 1 | | | +| `state` | state | | | | | | + ### osc::buzzer Buzzer oscillator diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index 3d8421aa..9c9f0e79 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -56,7 +56,7 @@ think_add_plugin_category(misc latch noisegate midi2freq midi2range think_add_plugin_category(mixer add fade mul pan) think_add_plugin_category(osc static simple sinsaw buzzer softsqr softsqr2 - fmop sample grain pad + fmop blep sample grain pad bandosc shapeo multiwave multisined window noise) diff --git a/plugins/osc/blep.cpp b/plugins/osc/blep.cpp new file mode 100644 index 00000000..4e235835 --- /dev/null +++ b/plugins/osc/blep.cpp @@ -0,0 +1,207 @@ +/* + * Copyright (C) 2004-2026 The thinksynth authors + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General + * Public License along with this program; if not, write to the + * Free Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. + */ + +/* A saw, a pulse and a triangle that do not alias. + * + * `osc::simple' draws its saw as a straight line that drops to the + * bottom in one sample. That drop is a step, and a step has harmonics + * all the way up -- past Nyquist, where they fold back down as tones + * that have nothing to do with the note. On a high note they are louder + * than the note's own upper partials, and they move the wrong way when + * the pitch bends: the "digital" in a cheap synth's sound. + * + * POLYBLEP rounds the step off. A band-limited step is a sinc's + * integral; two samples of a polynomial either side of the jump are a + * close enough fit to it to push the folded harmonics 40 dB or more + * down, for the price of a couple of multiplies when a cycle wraps. + * The pulse is two such steps, one at the wrap and one at `pw'. + * + * THE TRIANGLE IS THE PULSE INTEGRATED, with a leak, so it inherits the + * pulse's band-limiting: a triangle's corners are where a square's + * steps were. The leak keeps a pulse width off one half from walking it + * away from zero, and the scale keeps it at full scale at every pitch. + * + * `phase' is where a voice's cycle starts, 0 to 1, read on its first + * sample. Unison oscillators started at different phases do not begin + * with all their edges together, which is the click a stack of saws + * makes on every note when they do. + * + * This is a new node rather than a fix to `osc::simple', because every + * patch in the tree was voiced against that one's aliasing. + */ + +#include +#include +#include +#include + +#include "think.h" + +#include "thArg.h" +#include "thPlugin.h" +#include "thPluginManager.h" +#include "thNode.h" +#include "thSynthTree.h" +#include "thSynth.h" + +enum {IN_FREQ, IN_WAVEFORM, IN_PW, IN_PHASE, OUT_ARG, OUT_SYNC, INOUT_STATE}; + +std::atomic args[INOUT_STATE + 1]; + +static const char desc[] = "Band-limited oscillator (PolyBLEP saw, pulse, triangle)"; +thPlugin::State mystate = thPlugin::ACTIVE; + +static const char *const blepWaveforms[] = { "Sawtooth", "Pulse", "Triangle" }; + +/* The correction for a unit step at phase 0, `t' the phase and `dt' the + increment: nonzero only within a sample either side of the edge. */ +static double polyBlep (double t, double dt) +{ + if (t < dt) + { + const double x = t / dt; + return x + x - x * x - 1; + } + if (t > 1 - dt) + { + const double x = (t - 1) / dt; + return x * x + x + x + 1; + } + return 0; +} + +void module_cleanup (thPlugin *plugin) +{ +} + +int module_init (thPlugin *plugin) +{ + plugin->setDesc (desc); + plugin->setState (mystate); + + args[IN_FREQ] = plugin->regArg("freq", thPlugin::ARG_IN); + plugin->setArgDesc(args[IN_FREQ], "Frequency"); + plugin->setArgUnits(args[IN_FREQ], "Hz"); + args[IN_WAVEFORM] = plugin->regArg("waveform", thPlugin::ARG_IN); + plugin->setArgDesc(args[IN_WAVEFORM], "Which wave"); + plugin->setArgValues(args[IN_WAVEFORM], blepWaveforms, + (int)(sizeof(blepWaveforms) / + sizeof(blepWaveforms[0]))); + args[IN_PW] = plugin->regArg("pw", thPlugin::ARG_IN); + plugin->setArgDesc(args[IN_PW], + "Pulse width: how much of the cycle is high"); + plugin->setArgRange(args[IN_PW], 0, 1); + plugin->setArgDefault(args[IN_PW], 0.5); + args[IN_PHASE] = plugin->regArg("phase", thPlugin::ARG_IN); + plugin->setArgDesc(args[IN_PHASE], + "Where the cycle starts, read on the voice's first " + "sample"); + plugin->setArgRange(args[IN_PHASE], 0, 1); + + args[OUT_ARG] = plugin->regArg("out", thPlugin::ARG_OUT); + plugin->setArgDesc(args[OUT_ARG], "The wave"); + plugin->setArgRange(args[OUT_ARG], TH_MIN, TH_MAX); + plugin->setArgUnits(args[OUT_ARG], "full scale"); + args[OUT_SYNC] = plugin->regArg("sync", thPlugin::ARG_OUT); + plugin->setArgDesc(args[OUT_SYNC], + "1 on the sample the cycle wraps, 0 otherwise"); + plugin->setArgRange(args[OUT_SYNC], 0, 1); + + /* [0] the phase, [1] the triangle's integrator, [2] started. */ + args[INOUT_STATE] = plugin->regArg("state", thPlugin::ARG_STATE); + + return 0; +} + +int module_callback (thNode *node, thSynthTree *mod, unsigned int windowlen, + unsigned int samples) +{ + thArg *in_freq = mod->getArg(node, args[IN_FREQ]); + thArg *in_waveform = mod->getArg(node, args[IN_WAVEFORM]); + thArg *in_pw = mod->getArg(node, args[IN_PW]); + thArg *in_phase = mod->getArg(node, args[IN_PHASE]); + thArg *inout_state = mod->getArg(node, args[INOUT_STATE]); + + double phase = (*inout_state)[0]; + double tri = (*inout_state)[1]; + const bool started = (*inout_state)[2] > 0; + float *state = inout_state->allocate(3); + + float *out = mod->getArg(node, args[OUT_ARG])->allocate(windowlen); + float *sync = mod->getArg(node, args[OUT_SYNC])->allocate(windowlen); + + if (!started) + { + const float p = (*in_phase)[0]; + phase = thIsFinite(p) ? p - floor(p) : 0; + } + + for (unsigned int i = 0; i < windowlen; i++) + { + const double dt = thBoundFreq((double)(*in_freq)[i], samples) / + (double)samples; + const int wave = (int)(*in_waveform)[i]; + double pw = (*in_pw)[i]; + double y; + + if (!thIsFinite(pw) || pw <= 0) + pw = 0.5; + /* Both edges a sample's increment apart at least, or the two + corrections overlap and the pulse vanishes. */ + if (pw < dt) + pw = dt; + if (pw > 1 - dt) + pw = 1 - dt; + + if (wave == 0) + { + y = 2 * phase - 1 - polyBlep(phase, dt); + } + else + { + double t2 = phase + 1 - pw; + t2 -= floor(t2); + y = (phase < pw ? 1.0 : -1.0) + polyBlep(phase, dt) - + polyBlep(t2, dt); + if (wave == 2) + { + tri = dt * y + (1 - dt) * tri; + y = tri * 4; + } + } + + out[i] = (float)(TH_MAX * y); + + phase += dt; + if (phase >= 1) + { + phase -= floor(phase); + sync[i] = 1; + } + else + { + sync[i] = 0; + } + } + + state[0] = (float)phase; + state[1] = (float)tri; + state[2] = 1; + + return 0; +} From 26e939f19037c00a3738883804fe2dd92a283b0d Mon Sep 17 00:00:00 2001 From: Misha Nasledov Date: Sun, 4 Oct 2026 11:05:31 -0700 Subject: [PATCH 2/4] dsp: analog, a drifting band-limited polysynth Three osc::blep in unison spread across the stereo field, a second oscillator and a sub, each wandering on its own misc::drift seeded from the note, into two filt::svf in series for 24 dB an octave. --- dsp/analog.dsp | 280 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 dsp/analog.dsp diff --git a/dsp/analog.dsp b/dsp/analog.dsp new file mode 100644 index 00000000..cda2ed9e --- /dev/null +++ b/dsp/analog.dsp @@ -0,0 +1,280 @@ +# Analog -- a polysynth voice that is never quite in tune. +# +# What makes a vintage polysynth sound like one is mostly what its +# designers were fighting: oscillators that wander a few cents, a +# filter that sits slightly differently on every voice, two copies of a +# saw that are never in phase. Take that away and the same circuit +# sounds like a plug-in. This graph puts it back on purpose. +# +# THE OSCILLATORS DO NOT ALIAS. Three osc::blep saws (or pulses, or +# triangles) at the center and `Unison' cents either side, a second +# oscillator `Interval' semitones away, and a square an octave down for +# the sub. PolyBLEP is what keeps a high note from growing tones that +# are not in it. +# +# EVERY OSCILLATOR DRIFTS ON ITS OWN. A misc::drift per oscillator moves +# its pitch up to `Drift' cents at about a third of a hertz, seeded from +# the note and the oscillator so no two wander alike -- and a piece +# replays the same, because the seed is the note and not a clock. The +# cutoff drifts too, by a few percent. +# +# THE UNISON IS SPREAD. The flat copy leans left and the sharp one right +# by `Spread', and the center one is in both: the beating between them +# becomes width rather than a wobble in the middle. +# +# THE FILTER IS 24 dB AN OCTAVE: two filt::svf low-passes in series, +# the resonance on the second, the cutoff opened by its own envelope and +# by velocity, and tracking the keyboard by `Key Track'. + +name "Analog"; +author "Misha Nasledov"; +description "A drifting, band-limited polysynth: three unison oscillators spread in stereo, a second oscillator, a sub, and a 24 dB filter."; +category "Leads and stabs"; + + @wave1 = 0; + @wave1.widget = 1; + @wave1.min = 0; + @wave1.max = 2; + @wave1.step = 1; + @wave1.values = "Sawtooth,Pulse,Triangle"; + @wave1.label = "Wave"; + @wave1.group = "Oscillators"; + + @wave2 = 1; + @wave2.widget = 1; + @wave2.min = 0; + @wave2.max = 2; + @wave2.step = 1; + @wave2.values = "Sawtooth,Pulse,Triangle"; + @wave2.label = "Wave 2"; + @wave2.group = "Oscillators"; + + @semi2 = -12; + @semi2.widget = 1; + @semi2.min = -24; + @semi2.max = 24; + @semi2.step = 1; + @semi2.label = "Interval (semitones)"; + @semi2.group = "Oscillators"; + + @mix2 = 0.3; + @mix2.widget = 1; + @mix2.min = 0; + @mix2.max = 1; + @mix2.label = "Osc 2 Level"; + @mix2.group = "Oscillators"; + + @pw = 0.4; + @pw.widget = 1; + @pw.min = 0.05; + @pw.max = 0.95; + @pw.label = "Pulse Width"; + @pw.group = "Oscillators"; + + @sub = 0.2; + @sub.widget = 1; + @sub.min = 0; + @sub.max = 1; + @sub.label = "Sub"; + @sub.group = "Oscillators"; + + @unison = 7; + @unison.widget = 1; + @unison.min = 0; + @unison.max = 40; + @unison.label = "Unison (cents)"; + @unison.group = "Warmth"; + + @spread = 0.7; + @spread.widget = 1; + @spread.min = 0; + @spread.max = 1; + @spread.label = "Spread"; + @spread.group = "Warmth"; + + @drift = 4; + @drift.widget = 1; + @drift.min = 0; + @drift.max = 25; + @drift.label = "Drift (cents)"; + @drift.group = "Warmth"; + + @cutoff = 1200; + @cutoff.widget = 1; + @cutoff.min = 40; + @cutoff.max = 12000; + @cutoff.label = "Cutoff (Hz)"; + @cutoff.group = "Filter"; + + @res = 0.3; + @res.widget = 1; + @res.min = 0; + @res.max = 0.95; + @res.label = "Resonance"; + @res.group = "Filter"; + + @envamt = 2500; + @envamt.widget = 1; + @envamt.min = 0; + @envamt.max = 10000; + @envamt.label = "Env Amount (Hz)"; + @envamt.group = "Filter"; + + @keytrack = 0.5; + @keytrack.widget = 1; + @keytrack.min = 0; + @keytrack.max = 1; + @keytrack.label = "Key Track"; + @keytrack.group = "Filter"; + + @fa = 5 ms; + @fa.widget = 1; + @fa.min = 0 ms; + @fa.max = 3000 ms; + @fa.label = "Attack"; + @fa.group = "Filter Envelope"; + + @fd = 500 ms; + @fd.widget = 1; + @fd.min = 0 ms; + @fd.max = 5000 ms; + @fd.label = "Decay"; + @fd.group = "Filter Envelope"; + + @fs = 0.3; + @fs.widget = 1; + @fs.min = 0.01; + @fs.max = 1; + @fs.label = "Sustain"; + @fs.group = "Filter Envelope"; + + @fr = 400 ms; + @fr.widget = 1; + @fr.min = 0 ms; + @fr.max = 5000 ms; + @fr.label = "Release"; + @fr.group = "Filter Envelope"; + + @a = 5 ms; + @a.widget = 1; + @a.min = 0 ms; + @a.max = 3000 ms; + @a.label = "Attack"; + @a.group = "Envelope"; + + @d = 400 ms; + @d.widget = 1; + @d.min = 0 ms; + @d.max = 5000 ms; + @d.label = "Decay"; + @d.group = "Envelope"; + + @s = 0.7; + @s.widget = 1; + @s.min = 0.01; + @s.max = 1; + @s.label = "Sustain"; + @s.group = "Envelope"; + + @r = 300 ms; + @r.widget = 1; + @r.min = 70 ms; + @r.max = 5000 ms; + @r.label = "Release"; + @r.group = "Envelope"; + +node ionode { + channels = 2; + out0 = vcal->out; + out1 = vcar->out; + play = env->play; +}; + +node freq misc::midi2freq { + note = ionode->note; +}; + +# One wanderer per oscillator, in cents, and one for the cutoff. +node driftc misc::drift { rate = 0.3; depth = @drift; seed = ionode->note; }; +node driftl misc::drift { rate = 0.37; depth = @drift; seed = ionode->note + 1000; }; +node driftr misc::drift { rate = 0.29; depth = @drift; seed = ionode->note + 2000; }; +node drift2 misc::drift { rate = 0.33; depth = @drift; seed = ionode->note + 3000; }; +node driftf misc::drift { rate = 0.2; depth = 0.05; seed = ionode->note + 4000; }; + +node oscc osc::blep { + freq = freq->out * exp2(driftc->out / 1200); + waveform = @wave1; + pw = @pw; + phase = 0; +}; +node oscl osc::blep { + freq = freq->out * exp2((driftl->out - @unison) / 1200); + waveform = @wave1; + pw = @pw; + phase = 0.33; +}; +node oscr osc::blep { + freq = freq->out * exp2((driftr->out + @unison) / 1200); + waveform = @wave1; + pw = @pw; + phase = 0.67; +}; +node osc2 osc::blep { + freq = freq->out * exp2((@semi2 * 100 + drift2->out) / 1200); + waveform = @wave2; + pw = @pw; + phase = 0.5; +}; +node subosc osc::blep { + freq = freq->out * 0.5; + waveform = 1; + pw = 0.5; + phase = 0.25; +}; + +# What both sides share, and the unison leaning each way. +node common math::add { + in0 = oscc->out + osc2->out * @mix2; + in1 = subosc->out * @sub; +}; +node mixl math::add { + in0 = common->out + oscl->out * (1 + @spread) * 0.5; + in1 = oscr->out * (1 - @spread) * 0.5; +}; +node mixr math::add { + in0 = common->out + oscr->out * (1 + @spread) * 0.5; + in1 = oscl->out * (1 - @spread) * 0.5; +}; + +node fenv env::adsr { + a = @fa; + d = @fd; + s = @fs; + r = @fr; + trigger = ionode->trigger; +}; + +node cut math::min { + in0 = (@cutoff + fenv->out * @envamt * ionode->velocity) * + exp2(@keytrack * (ionode->note - 60) / 12) * (1 + driftf->out); + in1 = 18000; +}; + +node lp1l filt::svf { in = mixl->out * 0.2; cutoff = cut->out; res = 0; }; +node lp2l filt::svf { in = lp1l->out_low; cutoff = cut->out; res = @res; }; +node lp1r filt::svf { in = mixr->out * 0.2; cutoff = cut->out; res = 0; }; +node lp2r filt::svf { in = lp1r->out_low; cutoff = cut->out; res = @res; }; + +node env env::adsr { + a = @a; + d = @d; + s = @s * ionode->velocity; + r = @r; + p = ionode->velocity; + trigger = ionode->trigger; +}; + +node vcal mixer::mul { in0 = lp2l->out_low; in1 = env->out; }; +node vcar mixer::mul { in0 = lp2r->out_low; in1 = env->out; }; + +io ionode; From 31f06e1a759cbaee30fde4ac9e5f7014ec3fa010 Mon Sep 17 00:00:00 2001 From: Misha Nasledov Date: Sun, 4 Oct 2026 11:59:40 -0700 Subject: [PATCH 3/4] osc: blep fixes from review The phase and the triangle's integrator step in float, as they are kept between windows, so one sample a window and five hundred agree. A waveform that is NaN or outside 0..2 plays the saw rather than reaching a cast. The triangle integrates the pulse less its mean, divided by pw (1 - pw), from where the wave is at its starting phase, with a slow leak: centered and within full scale at any width, where the default patch's 0.4 had put it near -1.7. The sub drifts on its own like every other oscillator in analog.dsp. statecheck covers the bounds, the selector, phase, sync, a C7 saw's folded 21st harmonic and window agreement. --- dsp/analog.dsp | 3 +- plugins/osc/blep.cpp | 67 +++++++++++++------- scripts/statecheck.cpp | 140 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 22 deletions(-) diff --git a/dsp/analog.dsp b/dsp/analog.dsp index cda2ed9e..f56966cd 100644 --- a/dsp/analog.dsp +++ b/dsp/analog.dsp @@ -200,6 +200,7 @@ node driftl misc::drift { rate = 0.37; depth = @drift; seed = ionode->note + 100 node driftr misc::drift { rate = 0.29; depth = @drift; seed = ionode->note + 2000; }; node drift2 misc::drift { rate = 0.33; depth = @drift; seed = ionode->note + 3000; }; node driftf misc::drift { rate = 0.2; depth = 0.05; seed = ionode->note + 4000; }; +node drifts misc::drift { rate = 0.31; depth = @drift; seed = ionode->note + 5000; }; node oscc osc::blep { freq = freq->out * exp2(driftc->out / 1200); @@ -226,7 +227,7 @@ node osc2 osc::blep { phase = 0.5; }; node subosc osc::blep { - freq = freq->out * 0.5; + freq = freq->out * 0.5 * exp2(drifts->out / 1200); waveform = 1; pw = 0.5; phase = 0.25; diff --git a/plugins/osc/blep.cpp b/plugins/osc/blep.cpp index 4e235835..e4190639 100644 --- a/plugins/osc/blep.cpp +++ b/plugins/osc/blep.cpp @@ -31,10 +31,18 @@ * down, for the price of a couple of multiplies when a cycle wraps. * The pulse is two such steps, one at the wrap and one at `pw'. * - * THE TRIANGLE IS THE PULSE INTEGRATED, with a leak, so it inherits the + * THE TRIANGLE IS THE PULSE INTEGRATED, with a slow leak, so it inherits the * pulse's band-limiting: a triangle's corners are where a square's - * steps were. The leak keeps a pulse width off one half from walking it - * away from zero, and the scale keeps it at full scale at every pitch. + * steps were. The pulse's mean, 2 pw - 1, comes off before the integral, + * or a width off one half would integrate to a ramp the leak holds as a + * DC offset; and the result is divided by pw (1 - pw), the integral's + * height, so it spans full scale at every width and pitch -- a triangle + * at one half, leaning further into a saw as the width moves off it. + * + * THE STATE STEPS IN FLOAT, as osc::fmop's does, because it is kept in a + * float between windows: a running value held wider inside a window than + * across one comes out differently at one sample a window than at five + * hundred. * * `phase' is where a voice's cycle starts, 0 to 1, read on its first * sample. Unison oscillators started at different phases do not begin @@ -137,8 +145,8 @@ int module_callback (thNode *node, thSynthTree *mod, unsigned int windowlen, thArg *in_phase = mod->getArg(node, args[IN_PHASE]); thArg *inout_state = mod->getArg(node, args[INOUT_STATE]); - double phase = (*inout_state)[0]; - double tri = (*inout_state)[1]; + float phase = (*inout_state)[0]; + float tri = (*inout_state)[1]; const bool started = (*inout_state)[2] > 0; float *state = inout_state->allocate(3); @@ -148,16 +156,31 @@ int module_callback (thNode *node, thSynthTree *mod, unsigned int windowlen, if (!started) { const float p = (*in_phase)[0]; - phase = thIsFinite(p) ? p - floor(p) : 0; + float pw = (*in_pw)[0]; + + phase = thIsFinite(p) ? p - floorf(p) : 0; + + /* The integrator starts where the wave is at that phase, rather + than at 0 with an offset the leak takes a few cycles to bleed + away: at the bottom where the pulse goes high, rising + 2 (1 - pw) a cycle until pw, falling 2 pw after. */ + if (!thIsFinite(pw) || pw <= 0 || pw >= 1) + pw = 0.5f; + + tri = phase < pw ? -pw * (1 - pw) + 2 * (1 - pw) * phase + : pw * (1 - pw) - 2 * pw * (phase - pw); } for (unsigned int i = 0; i < windowlen; i++) { - const double dt = thBoundFreq((double)(*in_freq)[i], samples) / - (double)samples; - const int wave = (int)(*in_waveform)[i]; - double pw = (*in_pw)[i]; - double y; + const float dt = (float)(thBoundFreq((double)(*in_freq)[i], samples) / + (double)samples); + /* A selector: whole numbers 0 to 2, and anything else -- NaN + included, which no cast to int may be handed -- the saw. */ + const float w = (*in_waveform)[i]; + const int wave = (thIsFinite(w) && w >= 0 && w < 3) ? (int)w : 0; + float pw = (*in_pw)[i]; + float y; if (!thIsFinite(pw) || pw <= 0) pw = 0.5; @@ -170,18 +193,20 @@ int module_callback (thNode *node, thSynthTree *mod, unsigned int windowlen, if (wave == 0) { - y = 2 * phase - 1 - polyBlep(phase, dt); + y = 2 * phase - 1 - (float)polyBlep(phase, dt); } else { - double t2 = phase + 1 - pw; - t2 -= floor(t2); - y = (phase < pw ? 1.0 : -1.0) + polyBlep(phase, dt) - - polyBlep(t2, dt); + float t2 = phase + 1 - pw; + t2 -= floorf(t2); + y = (phase < pw ? 1.0f : -1.0f) + (float)polyBlep(phase, dt) - + (float)polyBlep(t2, dt); if (wave == 2) { - tri = dt * y + (1 - dt) * tri; - y = tri * 4; + /* The leak only has rounding to bleed off now, so it is + slow: a leak of `dt' bends the slopes visibly. */ + tri = dt * (y - (2 * pw - 1)) + (1 - dt / 64) * tri; + y = tri / (pw * (1 - pw)); } } @@ -190,7 +215,7 @@ int module_callback (thNode *node, thSynthTree *mod, unsigned int windowlen, phase += dt; if (phase >= 1) { - phase -= floor(phase); + phase -= floorf(phase); sync[i] = 1; } else @@ -199,8 +224,8 @@ int module_callback (thNode *node, thSynthTree *mod, unsigned int windowlen, } } - state[0] = (float)phase; - state[1] = (float)tri; + state[0] = phase; + state[1] = tri; state[2] = 1; return 0; diff --git a/scripts/statecheck.cpp b/scripts/statecheck.cpp index 257f0398..c9bdd93f 100644 --- a/scripts/statecheck.cpp +++ b/scripts/statecheck.cpp @@ -3943,6 +3943,145 @@ static void checkSimple (const string &pluginPath) "and at five hundred"); } +/* ---- osc::blep ---------------------------------------------------------- */ + +static vector blepGraph (float freq, float wave, float pw, + float phase) +{ + NodeSpec n; + + n.name = "osc"; + n.spelling = "osc/blep"; + n.values.push_back(Value{ "freq", freq }); + n.values.push_back(Value{ "waveform", wave }); + n.values.push_back(Value{ "pw", pw }); + n.values.push_back(Value{ "phase", phase }); + + return vector(1, n); +} + +static double meanOf (const vector &v, size_t from) +{ + double sum = 0; + + for (size_t i = from; i < v.size(); i++) + sum += v[i]; + + return sum / (double)(v.size() - from); +} + +static void checkBlep (const string &pluginPath) +{ + const struct { float wave, pw; const char *what; } shapes[] = { + { 0, 0.5f, "the saw" }, + { 1, 0.5f, "a square" }, + { 1, 0.1f, "a narrow pulse" }, + { 2, 0.5f, "the triangle" }, + { 2, 0.1f, "the triangle at a width of 0.1" }, + { 2, 0.9f, "the triangle at a width of 0.9" }, + }; + + for (const auto &sh : shapes) + { + vector got; + string why; + + if (!render1(pluginPath, blepGraph(440, sh.wave, sh.pw, 0), "osc", + "out", 256, TH_DEFAULT_SAMPLES, got, why)) + { + fail(string("osc::blep renders ") + sh.what, why); + continue; + } + + /* A pulse's mean is its width's business; everything else is + centered, the triangle included at any width. Past full scale + only by a correction's overshoot. */ + const double mean = meanOf(got, 4410); + const bool centered = sh.wave == 1 || fabs(mean) < 0.01; + + okOrFail(allFinite(got) && peak(got, 0) <= 1.1 && centered, + string("osc::blep: ") + sh.what + " stays within full " + "scale" + (sh.wave == 1 ? "" : ", centered"), + "peak " + num(peak(got, 0)) + ", mean " + num(mean)); + } + + /* A selector that is not one of the three plays the saw. */ + { + vector saw, odd; + string why; + bool same = render1(pluginPath, blepGraph(440, 0, 0.5f, 0), "osc", + "out", 256, 4096, saw, why); + const float odds[] = { NAN, 7, -3 }; + + for (float w : odds) + same = same && render1(pluginPath, blepGraph(440, w, 0.5f, 0), + "osc", "out", 256, 4096, odd, why) && + memcmp(saw.data(), odd.data(), + saw.size() * sizeof(float)) == 0; + + okOrFail(same, "osc::blep: a waveform of NaN, 7 or -3 plays the saw", + why); + } + + /* `phase' is where the cycle starts: a saw a quarter in is at -0.5. */ + { + vector got; + string why; + + const bool rendered = render1(pluginPath, + blepGraph(100, 0, 0.5f, 0.25f), "osc", + "out", 256, 256, got, why); + + okOrFail(rendered && fabs(got[0] + 0.5) < 0.01, + "osc::blep: `phase' sets where the cycle starts", + rendered ? "first sample " + num(got[0]) : why); + } + + /* `sync' once a cycle: 440 in a second. */ + { + vector got; + string why; + int wraps = 0; + + if (render1(pluginPath, blepGraph(440, 0, 0.5f, 0), "osc", "sync", + 256, TH_DEFAULT_SAMPLES, got, why)) + for (float v : got) + wraps += v > 0; + + okOrFail(abs(wraps - 440) <= 1, + "osc::blep: `sync' fires once a cycle", + why.empty() ? num(wraps) + " in a second at 440 Hz" : why); + } + + /* The point of the node. A C7 saw's 21st harmonic is at 43953 Hz and + folds to 147; a naive saw puts it there at a 21st of the fundamental + (-26 dB), and the corrections are what take it down. */ + { + vector got; + string why; + const double f0 = 2093.0045; + const double alias = TH_DEFAULT_SAMPLES - 21 * f0; + + if (!render1(pluginPath, blepGraph((float)f0, 0, 0.5f, 0), "osc", + "out", 256, TH_DEFAULT_SAMPLES, got, why)) + fail("osc::blep renders a C7 saw", why); + else + { + const double db = 20 * log10( + bin(got, 0, TH_DEFAULT_SAMPLES, alias) / + bin(got, 0, TH_DEFAULT_SAMPLES, f0)); + + okOrFail(db < -40, "osc::blep: a C7 saw's folded 21st harmonic " + "is more than 40 dB under its fundamental", + num(db) + " dB"); + } + } + + windowsAgree(pluginPath, blepGraph(440, 2, 0.3f, 0.1f), "osc", "out", + "osc::blep: the same triangle at one sample a window and at " + "five hundred"); +} + /* ---- osc::sample -------------------------------------------------------- */ /* The node that plays a file, and the only one here whose input is not a @@ -7255,6 +7394,7 @@ int main (int argc, char **argv) checkFdn(pluginPath); checkFmop(pluginPath); checkSimple(pluginPath); + checkBlep(pluginPath); checkSample(pluginPath); checkGrain(pluginPath); checkDrift(pluginPath); From c9385b9905d710eecb73e478d3287c4d0ef67093 Mon Sep 17 00:00:00 2001 From: Misha Nasledov Date: Sun, 4 Oct 2026 12:21:48 -0700 Subject: [PATCH 4/4] osc: blep's selector comment says what it does The whole part picks, as in osc::simple, whose "3.4 is a triangle and so is 3.9" is the convention the stepped control is drawn from. --- plugins/osc/blep.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/osc/blep.cpp b/plugins/osc/blep.cpp index e4190639..9f8d17ef 100644 --- a/plugins/osc/blep.cpp +++ b/plugins/osc/blep.cpp @@ -175,8 +175,9 @@ int module_callback (thNode *node, thSynthTree *mod, unsigned int windowlen, { const float dt = (float)(thBoundFreq((double)(*in_freq)[i], samples) / (double)samples); - /* A selector: whole numbers 0 to 2, and anything else -- NaN - included, which no cast to int may be handed -- the saw. */ + /* A selector, read as osc::simple reads its own: the whole part + picks, so 1.5 is the pulse. Anything under 0 or from 3 up, and + NaN, which no cast to int may be handed, is the saw. */ const float w = (*in_waveform)[i]; const int wave = (thIsFinite(w) && w >= 0 && w < 3) ? (int)w : 0; float pw = (*in_pw)[i];