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.