The AbstractBlitOperator handles a model where an oscillator generates
an impulse buffer, but requires pitch tuning, drift, FM, and DAC emulation.
Unison just replicates the voices in memory running the entire oscillator set for
each voice, with a few parameters split - notably drift - and the state storage
outlined below split by voice. As such the state is often indexed by a voice. For
the rest of this description I'll leave out the unison splits. Similarly the stereo
implementation just adds pairs (for oscbuffer there is oscbufferR and so on)
and here I'll just document the mono implementation
## Overall operating model
Assume we have some underlying waveform which we either have in memory or which
we generate using an algorithm. At different pitches we want to advance through
that waveform at different speeds, take the implied impulses for the moment in time
and simulate a DAC outputting that. The common form of that waveform is that it is
digital - namely it is represented as a set of impulse values at a set of times -
but those times do not align with the sample points.
In code that looks as follows
- The oscillator has a phase pointer (oscstate) which indicates where we are in the
internal stream.
- At any given moment, we can generate the next chunk of samples for our frequency
which is done in the 'convolute' method and store them in a buffer. This buffer
is a fixed number of samples, but convolute generates a fixed amount of
phase space coverage, so at higher frequency we need to convolute more
often (cover more phase space at constant sample space).
- in our process loop, we extract those samples from the buffer to the output.
At any given convolution moment, we store into the buffer at the state variable
bufpos.
- If we have extracted our set of samples, we re-generate the next chunk by convoluting.
So basically we have a couple of arrows pointing around. oscstate, which shows us
how much phase space is covered up to bufpos of buffer and the simple
march of time that comes from calling process_block. When we are out of state
space (namely, oscstate < BLOCK_SIZE * wavelength) we need to reconvolve and
fill our buffer and increase our oscstate pointer. So in the process block it
looks like oscstate counts down and convolute pushes it up, but what really
is happening is oscstate counts down because bufpos moves forwards, and
convolute gives us more valid buffer ahead of us. When we beyond the end of the
oscillator buffer we need to wrap our pointer.
The storage buffer is sized so there is enough room to run a FIR model of the DAC
forward in time from the point of the current buffer. This means when we wrap
the buffer position we need to copy the back-end FIR buffer into the front of the new
buffer. Other than that subtletly the buffer is just a ring.
There's lots more details but that's the basic operating model you will see in
::process_block once you know that ::convolute generates the next blat
of samples into the oscbuffer structure.
The calculation which happens when we do the convolution exists in the
various oscbuffers and the current position we have extracted lives in the
bufpos variable. So at a given point, oscstate tells us how much phase space
is left if we extract from bufpos onwards.
The convolute method, then, is the heart of the oscillator. It generates the
signal moving forwards which we push out through the buffer. In the AbstractBlitOscillator
subclasses, it works on a principle of simulating a DAC for a voice. A little theory.
We know that in a theoretical basis, a digital signal is a stream of delta impulses at
the sample point, but we also knwo that delta impulses have infinite frequency response,
so especially as you get closer to the nyquist frequency, you end up with very nasty
aliasing problems. Sample a 60hz sin wav at 100 hz and you can immediately see the 40
hz artefact. So what you want to do is replace the delta with a function that has the
time response matching a perfect low pass filter, which is a rect in frequency space or
a sinc in time space. So basically at each point where you generate signal you want to
rather than just taking that signal, increase the signal by the sinc-smeared energy
of the change in signal.
Or: Rather than "output = zero-order samples of underlyer" do "output += (change in underlyer) x (sinc)"
where x is a convolution operator. Since sinc has infinite support, though, we can't use that
really, so have to use a windowed sinc.
Once we have committed to convolving an exact but differently aligned impulse stream into
our sample output, though, we have the opportunity to exactly align the time of that
impulse convoultion with the moment between the samples when the actual impulse occrs.
So the convolution has to manage a couple of dimensions of time. When we call :;convolute
remember, it is because we don't have enough buffer phase space computed for our current block.
So ::convolute is filling a block in the "future" of our current pointer. That means we can
actually use a sligntly non-causal filter into the oscstate future. So mechanically we end
up implementing "oscbuffer [i + futurelook] = sum(impulse chage) * impulse[i]"
Surge adds one last wrinkle, which is that impulse function depends on how far between a sample
you are. The peak of the function should happen exactly at the point intra-sample. To do that
it makes two tables. The first is a table of the windowed sinc at 256 steps between 0 and 1 sample.
The second is the derivative of that windowed function with respect to position which allows us
to make a first order taylor correction to the window. Somewhat confusingly, but very efficiently,
these two tables are stored in one data structure "sinctable", with an indexing structure that gives
a window, a window derivative, the next window, the next window derivative, etc...
But the end result is we do a calclulation which amounts to
while( our remaining osc state doesn't cover enough phase space ) <<- this is in process block
convolute <<- do this call
Figure out our next impulse and change in impulse. Call that change g.
figure out how far in the future that impulse spans. Call that delay.
fill in the oscbuffer in that future with the windowed impulse
oscbuffer[pos + i] = oscbuffer[pos + i] + g * ( sincwindow[i] + dt * dsincwindow[i] )
advance oscstate by the amount of phase space we have covered
Unfortunately, to do this efficiently the code is a bit inscrutable, hence this comment. Also
some of the variable names (lipol128 is not an obvious name for the 'dt' above) makes the code hard
to follow. As such, in this implementation I've added quite a lot of comments to the ::convolute method.
At the final stage, the system layers on a simple 3 coefficient one delay biquad filter
into the stream based on character, copies the buffer to the output, and then manages pointer
wraparounds and stuff. That's all pretty mechanical.