Hi,
I am deep into making my next plugin, and need to know if I’m committing any dsp sins.
I am storing the state of my plugin in a ValueTree, an apvts in the audio processor. I’m not really using any parameters, only for things like master gain. The majority of the state is stored as custom data structures in the apvts.state that I don’t want to automate. This is mainly information about splines.
The GUI thread modifies this value tree, adding children, setting properties etc. In another part of the program I have a structure that listens to changes on different branches of the valuetree, and sets some flags to tell the editor or the processor that they need to update their internal variables.
So in the GUI thread I can check every paint call if(flags.editor == true) and update any internal state of the editor, and in processBlock I check if(flags.processor == true) and update any internal state in the processor.
So in the process block I’m reading directly from the value tree whenever it changes, copying values from children etc. And the same in the GUI thread.
How safe is this? I’ve decided to completely use the ValueTree because it makes all the undo/redo behaviour super easy and requires no extra UndoableAction classes. I tried to mess around with custom Undoable actions but it was too finnicky.
How likely is it that the processor will read from the ValueTree as its being modified and what could be the negative consequnces?
Furthermore, part of my plugin involves storing volume envelopes, which I’m storing as a 2592 long float array. the number 2592 comes from 2x2x2x2x2x3x3x3x3 so I can divide it into triplets and 32nds easily. But this seems like it’ll inflate the memory footprint of my state too much, does anyone have any suggestions for how to handle volume envelopes like this? I can’t use a normal ADSR representation for these volume envelopes.
I would say there is a data race. Unlike apvts, valuetree is not thread safe. However, I would guess you can use atomics or CAS/RCU to let audio thread get the updates in a safe way.
I don’t think something like std::array<float, 2592> is a big memory problem. About 10KB if I do math correctly.
Thanks for the reply, i thought as much. So when the ValueTree::Listener callbacks gets invoked on the message thread, I can perform any caching there directly from the value tree to a lock free data structure.
When I asked ChatGPT it suggested using a std::atomic<std::shared_ptr< T >>, however I see that compiler support for this is not ubiquitous. This data strucutre makes sense to me because the shared_ptr will be deleted only after the dsp has finished consuming it, even if the message thread has pushed new data. I can’t seem to find another elegant solution for this. What lock free data structures do you use?
Edit: I have used a Double Buffer to solve this for now, as far i can see it solves my problems but i still feel like something could go wrong
template <typename T>
class DoubleBuffer
{
public:
DoubleBuffer()
{
// Initialize both buffers
buffer1 = std::make_unique<T>();
buffer2 = std::make_unique<T>();
// Set the initial read and write buffers
readBuffer.store(buffer1.get(), std::memory_order_relaxed);
writeBuffer.store(buffer2.get(), std::memory_order_relaxed);
}
// Write new data to the write buffer
void write(const T& newData)
{
// Copy the new data into the write buffer
T* currentWriteBuffer = writeBuffer.load(std::memory_order_acquire);
*currentWriteBuffer = newData;
// Atomically swap the read and write buffers
writeBuffer.store(readBuffer.exchange(currentWriteBuffer, std::memory_order_acq_rel), std::memory_order_release);
}
// Read data from the read buffer
T read() const
{
// Atomically load the current read buffer
T* currentReadBuffer = readBuffer.load(std::memory_order_acquire);
return *currentReadBuffer; // Return a copy of the data
}
private:
std::unique_ptr<T> buffer1;
std::unique_ptr<T> buffer2;
std::atomic<T*> readBuffer{nullptr};
std::atomic<T*> writeBuffer{nullptr};
};
When I asked ChatGPT it suggested using a std::atomic<std::shared_ptr< T >>, however I see that compiler support for this is not ubiquitous.
It could work. But the details are very complicated AFAIK. Because you have to ensure the de-allocation does not happen on the audio thread. You may want to look at the RCU method (in fact, in this video Timur mentions that std::atomic<std::shared_ptr<>> is not lock-free, not sure about the current situation).
Edit: I have used a Double Buffer to solve this for now, as far i can see it solves my problems but i still feel like something could go wrong
I am not sure about this. It seems that it will go wrong if you write twice without read. You may refer to the following video.
What lock free data structures do you use?
It highly depends on the underlying data and which thread reads/writes the data. Atomics and FIFO should cover most cases.
Thanks for the reply and the clarification.
It looks like this “spin_on_write_object” that timur described is what I need, however I’m having some trouble implementing it in a concise manner. I’m trying to write a wrapper struct as he has done but theres a few things that aren’t clear to me:
Timurs implementation has a nice “lock_read()” function which returns a pointer to the data and atomically swaps a nullptr into the atomic<T*>. How can I get this elegant behaviour where I don’t need to swap the pointers back manually, he describes that the destructor of “read_ptr” does this automatically but I don’t see how since he uses “auto” here and doesn’t provide implementation details.
As fas as I can tell you have to exchange the pointer with nullptr atomically. Although there are only several lines of code, this thing is so tricky and can go wrong easily.
You may want to watch the second video (the one you want should be the CAS in the second video). And there is a nice library:
I have used those techniques several times (for coeff update, etc). But eventually I change all of them to simple atomics and FIFOs.
As far as I can tell, the CAS implementaiton in the second video is exactly the one the Timur is talking about in the first video, he references it directly and I believe I have implemented it as he describes. I have simply added a function “unlock” which swaps the pointer atomically again that I call once I have finished any reading operations.
I’m unsure how you mean you always return to atomics and FIFO, i can;t see how it would be done in my case.
Bascially in my case the data structure i need to pass from the gui thread to the audio thread is a 64 long std::array full of some structs that each hold 4 floats (it represents a spline), As well as the 2592 float array envelope. Only the gui thread modifies the state and only the audio thread consumes it. I understand this is a fundemental problem of audio plugins and real-time code in general and that there’s basically infinite solutions, I’m just trying to learn
As far as I can tell, the CAS implementaiton in the second video is exactly the one the Timur is talking about in the first video, he references it directly and I believe I have implemented it as he describes. I have simply added a function “unlock” which swaps the pointer atomically again that I call once I have finished any reading operations.
Yes, you are correct, those two are the same. The thing I dislike about CAS is the spin lock on the message thread. Although it won’t touch the audio thread, it is a still a bit dangerous as we use message thread for rendering.
The 6 floats in filter coeffs must get updated at the same time. However, I can pass freq/gain/Q atomics to audio thread and let the audio thread calculate the coeffs.
Is it the same for your case? Will std::array<std::atomic<float>, 2592> work for you? (WARNING: if you choose this, on the audio thread you may get the first half of the updated envelopeand the second half of the old envelope temporarily).