Understanding DSP code, specifically FillBuffer()

Hi,

I’ve been poking around the code posted on github, mainly concentrating on the clouds module hoping to learn a few things.

Things are going ok, there is one sticking point however, I can not figure out how FillBuffer() is called, sorry if this is a really dumb question, but any help understanding this would be appreciated.

FillBuffer is passed as an argument to the codec initialization routine. In drivers/codec.cc you’ll see that it is invoked in Codec::Fill, which is itself invoked whenever 50% of a DMA transfer on the I2S peripheral is completed.

Thanks for the quick reply!

Ok, I’ll look into this, think that’s enough to get me started.

Thanks again.

Apologies for necrobumping but I’ve been digging into some of this myself and this thread seemed like a good place to ask. FillBuffer() makes sense, but what I’m struggling with is making sense of the various buffers used (taking Clouds as an example).

Specifically, I’m having trouble drawing the connections between the workspace buffers allocated by GranularProcessor and the DMA buffers initialized by Codec — my understanding is that they are different buffers but at some point the contents of the workspace buffers will need to be transferred to the DMA tx/rx buffers and sent out to the codec. Where does this actually happen?

The workspace buffers in GranularProcessor are for recording audio, they don’t have anything to do with reading from/writing to the codec.

In short:

  • The DMA controller handles transfers from/to the codec, and this happens in the following buffers: Codec:: tx_dma_buffer_ and ```Codex:: rx_dma_buffer_````.
  • Whenever we start transmitting/receiving one half of the buffer, we call whatever function is pointed at FillBufferCallback and give it a pointer to the other half of the buffer, so that it is processed. That’s classic double buffering. Practically, FillBufferCallback points to clouds.cc's FillBuffer.
  • FillBuffer does a couple of things (handling the VU-meter, reading CVs), then passes these pointers to GranularProcessor::Process for the actual cloudsing.
  • At this point, the samples go through a fairly involved list of steps (stereo to mono mixing, mixing with the feedback signal, possibly some downsampling when the quality setting is set to half-sample-rate, granularization, diffusion, pitch-shifting, filtering, reverb). But we don’t really care about the original DMA buffers – we immediately read from them and convert the data to floats; and in the end whatever floats we have are converted back to integers and stuffed into the DMA buffers.

If the “buffer” you’re concerned with is the granularization buffer, look into GranularProcessor::ProcessGranular. You’ll see that the input samples are first recorded in the granular buffer (buffer_16_[i].WriteFade), and then, depending on the playback mode, various parts of this buffer are read in complicated ways (typically player_.Play(buffer_16_, parameters_, &output[0].l, size);).

3 Likes

Wish I had found this thread sooner! I was trying to wrap my head around exactly this. My case is a bit different, since I’m trying to use the onboard DAC, but it’s mostly the same.

I had managed to cobble together a “Hello DMA” project based on the small handful of tutorials I was able to find that fit the bill, and then went about wrapping up the DMA/DAC parts similar to the Clouds’ Codec class.

One key difference is I have been trying to use the DMA’s circular mode, though it appears to me that there’s no way to tell which address the DMA is currently reading from. DMA_GetCurrDataCounter() always returns 64 for me, which is the same as the buffer size I have set. Am I missing something, or am I simply better off using a double buffer instead?

Also, is there a reason for the codec Init() and Start() being separate functions? They seem to be called in direct succession.

Thanks for opening up all this code and packaging up the build environment, it’s really proving to be a valuable learning resource!

Sorry, I’ve never used DMA_GetCurrDataCounter(). Has the transfer been initiated?

The code is written in such a way that you can call Stop() and then Start() again with another callback. I think at some point I made use of this to use a different callback for the factory testing mode.

I ended up setting up the DMA in double-buffered circular mode and just filling the alternate buffers when the half-transfer interrupt fires. I’m using your callback technique, except that the callback in this case only handles a single sample at a time. My final fill buffer function called from that ISR looks like this:

void OnboardDAC::FillBuffer() {
	uint16_t *audioBuffer;
	if(DMA_GetCurrentMemoryTarget(DMA1_Stream5)) // returns 0 if DMA is reading from memory0, and 1 if it's reading from memory1
		audioBuffer = bufferA;
	else // currently transferring from buffer A, so prepare buffer B
		audioBuffer = bufferB;

	for(int i=0;i<DAC_BUFFERHALF;i++)
	{
		uint16_t* out = &audioBuffer[i];
		(*callback_)((uint16_t*)(out));
	}
}

Works like a charm!

Cool. You should consider switching to a single callback that processes a block instead of a single sample. That saves you some cycles for the call to your callback-function-pointer

More generally, you can save many CPU cycles by rendering things in blocks, because your state variables (your filter coefficients, the phase increment of your oscillator…) will remain stored in registers while the block is being rendered. There are some drawbacks, though: it’s not worth it if you allow audio-rate modulation of all synthesis parameters; and with larger blocks come higher latency.

A very common compromise is to run the modulations about an order of magnitude slower than the audio synthesis, recompute the modulations (envelopes, LFOs, modulation matrix) and thus filter coefficients/phase increments at the beginning of the block, then use them as constant (or linearly interpolate them) over the n samples of the block. These days, audio-rate everything is all the rage, though!

2 Likes