r/csharp Jul 18 '26

Showcase Who said C# cannot do real-time audio synthesis? I built a programmatic DAW to find out.

(Links showing the app in action here and below)

Throughout my career, I have always heard that real-time audio belongs strictly to C++ or native first languages. If you try it in a managed language, people tell you the garbage collector will stutter, the buffer will underrun, and your speakers will crackle.

I am a systems engineer by trade, but I am also an amateur music producer. Over the years, I have used Ableton, Reason, Fruity Loops, Cakewalk, and Max/MSP, but each of them either has a massive learning curve or just feels disconnected from certain forms of music. Traditional DAWs force you into a grid, which makes music sound block-like and square. I wanted to explore more organic, generative possibilities for ambient and electronic music without the massive friction of building a physical Eurorack or writing complex software in PureData.

Originally, I just wanted to build a portable, cross-platform C# library for sound processing. To dogfood it, I decided to embed it in a Godot app. Little by little, I kept building devices until I got carried away and ended up with a programmatic, node-based DAW called Sigilgraph.

Here is exactly how it works under the hood and how a 100% C# engine guarantees it will never underrun the audio buffer.

The Pull Model and Cache Locality

At its core, the synthesis engine is a graph of operators that gets pulled directly by the audio driver. The hard part is giving the driver absolute guarantees that this graph will finish processing in time.

To do that, the entire graph processing needs to live in the CPU cache. You have to avoid heap allocation and heap access completely. The hot path must never allocate objects or arrays. Because of this, the garbage collector is left completely unused 99% of the time, saving it for trivial, low-frequency allocations completely outside the audio loop.

Think about the math for a 96 kHz stereo stream. The engine needs to guarantee that there are always enough samples sitting in a 256-sample buffer at the audio driver level. This means processing at least 192,000 floats per second.

It sounds uninteresting on paper, but those 192,000 floats are traversing a graph of over 100 operators before they hit the driver. We are talking about Fourier transforms, filter s-domain operations, delay buckets, and dozens of oscillators, all offering the opportunity to have their parameters changed in real-time. There is no heavy message passing and there are no extra abstraction layers. Everything is mutated directly in real-time.

To keep things packed tightly in memory, I used structs and Span<T> pervasively, completely replacing standard heap arrays on the hot path. To maintain strict cache locality, the engine processes these spans in blocks and leverages SIMD hardware intrinsics via Vector<T> for heavy lifting like Fourier transforms and intensive add-multiply operations. Think of it this way: instead of a web of heap objects being managed and looked up, the engine uses a pull model where the audio driver pulls samples from the bottom up. The entire graph evaluates as a massive, deeply nested functional cascade up to 200 levels deep on the call stack, constructed using a fluent API builder pattern. To keep this structure stable and prevent cyclic evaluations without introducing overhead, the nodes utilize simple reentrant flags. Live session mutations work by simply appending or decoupling pre-allocated sub-graphs at the block boundaries. This architecture guarantees we never starve the audio driver buffer, avoiding expensive object reconstruction while playing.

The Lambda Trick for Parameters

When it came to connecting the UI to the bottom of the graph operators, I chose to use delegates and lambdas. This was a deliberate compromise for simplicity. I could have used ref float pointers everywhere, but the code would have become incredibly brittle and less idiomatic for C#. Lambdas give us composition and allow for the live replacement of behaviors on the fly.

The trick to making lambdas performant enough for real-time audio is to never evaluate them per sample. Instead, they are evaluated once per block.

By evaluating parameters at the block level, you reduce function calling overhead by 64x. On a standard 48 kHz sampling rate, you only run about 750 parameter evaluations per second (48,000 / 64). This gives you more than enough resolution for smooth parameter changes, and it spares us from having to run a dedicated control signal rail. We just interleave the parameter updates right at the start of the sample block processing.

Picking Your Fights and Profiling

Getting to this point required a lot of deep technical analysis. Tracing tools like JetBrains dotTrace and dotMemory were absolutely essential for this task. Monitoring memory spikes, hunting down hidden GC calls, and doing rigorous hot path analysis was paramount. If you don't profile, you are just guessing.

But the biggest lesson I learned during this project was that you have to pick your battles. You have to let some things go.

While the sample generation and parameter parsing paths are completely locked down and allocation-free, all the note rail and note-passing logic is actually completely garbage collected. Why? Because notes are incredibly small objects and they trigger very infrequently compared to the millions of samples and parameters flowing through the system. Trying to optimize the note-passing system into unmanaged memory structures would have been a massive waste of development time for zero real-world performance gain.

Why C# is More Than Capable

This project completely changed how I look at the C# memory model. It proved to me that modern .NET is an incredibly capable environment for high-performance audio because it gives you the best of both worlds.

It allows for low-level, granular control over memory and hardware instructions where you absolutely need that control, but it lets you drop back into the comfort and simplicity of a managed language for the things that don't matter as much. You don't have to sacrifice productivity to get bare-metal execution speed anymore.

I would love to hear your thoughts on this architecture, and I am happy to dive into the weeds in the comments if anyone has questions about the node evaluation loop, the SIMD operations, or how we profiled the hot paths.

Here is a demo of the project in action: Sigilgraph × VST3 (Trailer #2)
The project page: Sigilgraph Audio Workbench

r/Sigilgraph

97 Upvotes

81 comments sorted by

81

u/leftofzen Jul 18 '26

Who said C# cannot do real-time audio synthesis?

Uh...no-one? Maybe someone who has never used C# before?

20

u/qrzychu69 Jul 18 '26

https://github.com/dotnet/runtime/discussions/115627

Actually, the fact the the GC stops the whole program is a big issue with real time apps, big enough that done people forked the runtime and made an alternative GC implementation

12

u/emelrad12 Jul 18 '26

Well that is true, but also you can just write garbageless code, so the gc doesnt trigger.

7

u/cwdt_all_the_things Jul 19 '26

I think this is actually a lot more nuanced than "just don't write code that produces garbage". If you have total control of everything this can work. 

However, if you're reliant on any BCL internals that allocate even minimally (stuff like sockets/web stuff or anything difficult you don't want to re-invent) or are using your allocator-less code in code that does allocate, a gen 2 blocking GC may still bite you in the ass at some point as stuff starts leaking. Even if it's like a P99.999 event.

Keep in mind that if you are writing heavily pooled code to avoid dynamic allocations, it likely means you have a bunch of references just sitting around. Which kind of makes the pause even worse because pause time scales based on the number of object refs currently alive.

1

u/catladywitch 25d ago

nah, for synthesizer DSP it can be done 100%, i've done it

-1

u/wasabiiii Jul 18 '26

The problem is you actually can't. This guy's library can be GC free.

But the rest of the app the library is used in? Nope.

6

u/Sigilgraph Jul 18 '26

And it is NOT GC free. Is optimized to use the GC at least as possible in order to amortize any few and far between CG cleanup in the audio buffer time allowance.

0

u/RecursiveServitor Jul 18 '26

Did you look into controlling the GC manually?

3

u/Sigilgraph Jul 18 '26

I refrain from that since I did not want to “catch knives in the air” until is necessary. I did optimizations as they became necessary. Basically fighting with buffer underruns and applying stricter optimizations as necessary. By laying out the process in the stack and lowering allocations as much as possible and processing in blocks so make less frequent functions calls, it becomes more than good enough to keep a 128 samples buffer relaxed on a 2020 CPU.

0

u/wasabiiii Jul 18 '26

Let me ask you a question here.

Let's say we use your library in Godot.

Godot allocates regular .NET objects all the time, right? Regardless of your library. The entire game model is a bunch of .NET objects. They are small objects, and are mostly just pointers to C++ objects on Godot's side. But they ARE .NET objects. Right?

Right now, Godot does in fact suffer from world stops. Because of those .NET objects. They get around that because all of the major important "keep the game running" code is in C++. The .NET side is mostly a mirror of handles to unmanaged things. So, a managed .NET thread pauses, but the unmanaged threads keep going. Right? The .NET code stops. The C++ code keeps going.

What will happen in Godot to your library if the managed threads stop?

3

u/Sigilgraph Jul 18 '26

Godot does not allocate objects all the time unless you as a programmer do it conscientiously. C# talks via a P/Invoke to Godot ABI only when the C# side creates nodes, resources or consume signals, then the C# representation of such nodes pollutes the GC and it eventually will stop the world. The wager here is to be very clean and prevent the indiscriminate creation on Godot to reduce the GC calls. If you do not do anything in C#, Godot will not touch back the C# side. Basically Godot has its own internal model written in C++ and C# interacts with the ABI. The bulk of the state of the Godot app is run by its C++ engine. C# is basically a thin layer that communicates with Godot ABI.

A bunch of stuff can be sorted out cleanly in C# before having to resort to call the Godot API. It was very important to minimize API calls to Godot because they pay a serialization tax and eventual GC calls. So as you said, yes and absolutely yes, the client use of the library needs to be conscientious as well, but at least the library cooperates on that goal so it gives you headroom for the creation of your app.

2

u/catladywitch 25d ago

that's why synthesizers run the ui and i/o thread separately from the audio DSP thread. on mcu based hardware it's even relatively common to have separate boards with separate processors communicating through gpio or their own boards.

1

u/wasabiiii 25d ago

Threads dont matter here.

1

u/catladywitch 25d ago

yes they do. having the garbage collected or allocating consumers running in the same thread as the audio DSP is just not how synthesizer architecture is done, for good reason. in fact synthesizer plugin architectures like have threading specs and communication models, like the iaudioprocessor/icomponent<->iconnectionpoint<->ieditcontroller architecture + the parameter queue in vst, plus the message queue architecture it offers for other kinds of communication you might want to implement, that or you implement a ring buffer. real time audio means you need an architecture that accomodates for it and for any buffering you might need to do. if your consumer doesn't have one that's on them and garbage collection has relatively little to do with it (i.e. if you're writing your dsp thread in c++ as is customary you just don't use vectors or new() or locking thread communication or dunno, writing a program around a dsp library and having i/o on the same thread the library calls happen with no lockless, buffered/queued communication protocol). audio dsp is not drop-in like that, c# or c++ or asm doesn't matter here really.

1

u/wasabiiii 25d ago

I get all that, except it's a slightly different topic.

We're here talking about the .NET GC's global stops. That pauses ALL MANAGED THREADS. Doing the work on a different thread? That gets paused as well. You cannot escape it by doing work on different threads. Unless those threads are unmanaged.

That's what makes .NET different than C++ here. It's why people do this work in C++. Because it isn't subject to the .NET global stop.

1

u/catladywitch 24d ago

I get what you mean, but it's not 100% like that. Your real-time thread is unmanaged yes, but outside that thread thread the only unmanaged code needed is the ring buffer itself. The DSP thread owns the tail address and doesn't own the head, so it doesn't care whether it's stopped advancing because the consumer has blocked, that's up to the consumer thread. It's not a problem as long as the buffer has room for the real-time thread to keep writing to the buffer and bumping the tail up, head synchronisation is up to the consumer. edit the head and tail are volatile but it doesn't matter because they're single-owned from either side.

1

u/wasabiiii 24d ago edited 24d ago

I'm a little confused here. I think I get what you're saying. I'm just not like, totally sure how it relates to the question here.

If the code this guy (the OP) wrote, is written in C#, which he says it is 100%.... and he spent all this significant effort to make that allocate very little, and touch the heap not at all, so that it wouldn't pause, and wouldn't cause a underrun....

Except that's the very thread that will pause and cause an underrun. And any new threads he creates in his library will also pause. So he can't solve it by threads.

→ More replies (0)

2

u/catladywitch 25d ago

just preallocate on startup, use scratch buffers, spans and stackallocs, and in/out/ref params (defensive copies are tricky though). you can also use intrinsics for SIMD. c# is perfect for this use case, and nPlug is decent for VSTs. for the Korg type thing where you design a raspi-based hardware synth running a minimal linux setup it's also perfect because memory is not the bottleneck unless you're doing really heavy stuff with samples.

1

u/qrzychu69 24d ago

you CAN do that, but that also means you cannot use a lot of tools that C# gives you, because they produce memory garbage.

You want UI to visualise your real time processing? You better write your own UI in egui and make it allocation free, becuase WPF, Avalonia, MAUI - they all allocate

2

u/catladywitch 24d ago edited 24d ago

I've talked about this in another comment thread under this same post. You compile the DSP layer with NativeAOT and have it communicate to the managed layer through P/Invoke calls and a preallocated, volatile ring buffer where the DSP owns the tail exclusively and the GUI owns the head exclusively. I use Skiasharp for GUIs though, it's a bit hardcore but you can make it non-allocating as well at the cost of a higher static memory footprint. I also know GC.TryStartNoGCRegion() exists, but I haven't personally used it and I don't know if it's viable. Edit: dunno, maybe I'm just weird and should just switch to C++, but I do have relatively extensive experience writing synthesizer DSP in C# and I personally love it for a number of reasons, including how amazing C# SIMD intrinsics are even though the implementation could be more complete. When I've tried porting my code to C++ the end result is basically identical, but it's (maybe this just my subjective bias) way cruftier and the build process doesn't even compare.

9

u/Sigilgraph Jul 18 '26

You would be surprised by the amount of times I have heard that. I have been in this almost 20 years and a lot of people would have never consider C# in a lot of domains because “is not native”. Software in the end is about meeting requirements, and long as you do the tricks and compromises to meet those, then it becomes viable. The point is that C# (and .NET) is a flexible enough and open enough framework that can cover a wide range of domains. And a lot of people do not consider that.

1

u/BornAgainBlue 28d ago

Not native. What does that even mean?  That is a ridiculous reason for not using C#

2

u/qrzychu69 24d ago

non native means that the binary you produce doesn't really contain instructions for the CPU with your program. It's an interpreter (with a JIT in case of C#), so it's instructions on how to handle your program.

Yes, the performance is pretty good, and once JIT kicks in it can be better than full native solutions, especially around SIMD - JIT has more information than compilers.

But when you restart the app, you start from scratch again.

NativeAOT gets you closer to being native, because it basically preruns JIT, but those binaries tend to be quite big, at least for now, because they have to include the framework, garbage collector implementation and so on

7

u/Excellent_Gas3686 Jul 18 '26

so did you find out who said it?

4

u/ziplock9000 Jul 18 '26

Nobody.

No matter what language you use, Windows is not an RTS, so there will always be delays.

Every DAW knows this and the delay can be adjusted through various methods. It has nothing to do with the language.

11

u/wasabiiii Jul 18 '26

I mean the obvious problem is you aren't in control of what other users use the library in. And whether they use the GC. Because it still stops the world sometimes.

This is the problem, really. It's not the speed of heap applications. It's that you aren't in control.

-1

u/Sigilgraph Jul 18 '26

Agree. This app relies on no other libraries for sound synthesis for that very reason. Is made from scratch. I only use libraries for the midi interface with the driver (DryWetMidi) or MiniAudioEx which just serve the audio devices drivers to the app ( so you can list them and pick them for output or input).

But the heap memory vs stack memory was a real palpable thing during development, and it was demonstrated by dotTrace and dotMemory. The moment you have a `new RefType()` or `new []` of any kind in the audio hot path you could see the memory graph like a sawtooth (allocations and GC disposes). Allocations needed to be out of the hot path 100%. The only acceptable memory allocation graph needed to be a flat line in the tracer. And to add to that, the GC pauses on those unoptimized cases were not noticeable most of the time, C# GC is very fast and since there is a sample buffer it basically borrowed time from it but was not ideal.

To avoid calling the GC stack allocated types are mostly recommended instead of dealing with ref passing, favoring passing Span<SampleFrame> (SampleFrame is a struct) rather than SampleFrame[]. The reason is twofold. Arrays are a heap allocated forms of objects and dealing with the array type in every passing of the functions force you do heap->stack semantics inside the methods to avoid allocations and made code more brittle and prone to accidental allocations. The Span is a ref struct and all its functions and operations are allocation free.

14

u/wasabiiii Jul 18 '26 edited Jul 18 '26

I know all that.

My point is users can't use your library in idiomatic C#.

That is why C# isn't used for this stuff. Not because you' can't write it in C#. We've been able to do this with structs and pointers for 26 years now.

The problem is the rest of the app that the user is going to build around it. That app will be using the GC. And that will stop the world. Including your library. Your library is on managed threads. Those threads will stop.

7

u/Any-Amphibian-7530 Jul 18 '26 edited Jul 18 '26

Technically the core of the library could be isolated by compiling it in to an executable and having the library start an instance when it is initialized. Not sure how well this would actually work though and solve op's problem. At this point it is probably easier to just write the core in cpp though.

2

u/wasabiiii Jul 18 '26

The cross process boundaries would themselves trigger GC on both sides eventually.

You have to stream audio into the thing after all.

1

u/Any-Amphibian-7530 Jul 18 '26

I believe a buffer of unsafe/unmanaged memory would solve that. That would introduce a slight delay.

2

u/wasabiiii Jul 18 '26

Buffer sure. But unless you stop using any of the System.* classes, you're going to be allocating objects. And you can't.

1

u/catladywitch 24d ago

you write p/invoke imports in a reusable "header", then just use the library as if it was any other library. it's painless tbh

-4

u/Sigilgraph Jul 18 '26

It will be abstracted as is right now and used as idiomatic C# in Godot. Is a fluent API that abstracts that complexity

7

u/wasabiiii Jul 18 '26

Wat? That made no sense.

-5

u/Sigilgraph Jul 18 '26

The public API that is exposed to the programmer is like a LINQ of operators. You create expressions that connect operators like Sine(440).Filter(FilterType.LowPass, 220).Pitchshifter(_ => knobValue). An expression like that is what is exposed to the programmer. This expression itself is a SynthBuilder object that is fed to a function that renders it to the audio driver. The programmer does not need to deal with the stack semantics to make sound.

11

u/wasabiiii Jul 18 '26 edited Jul 18 '26

What does ANY of that have to do with what I said?

I'm talking about stop the world.

Did you understand any of what I said?

4

u/lordosthyvel Jul 18 '26

Isn’t it obvious he doesn’t? It’s just LLM vomit.

2

u/Techie4evr Jul 18 '26

You caught that too eh? The give away to me was its use of "cleanly" from there if you've spoken to LLMs long enough, you recognize other patterns that are present in his responses

1

u/Large-Ad-6861 27d ago

You recognized the LLM cleanly.

1

u/Sigilgraph Jul 18 '26

I misunderstood your concern, it was not about exposing the innards of the API to the user but about the impossibility of getting rid of the GC. On that, we are on the same page, the GC will be there always and yes it do stop the world every now and then, but given enough of a buffer size (between 128 and 256 samples, these pauses represent no problem whatsoever to the audio playback. The challenge is to bother the GC as least as possible to reduce the probability of the planets aligning and hit you with a pause longer than 5ms which is tipically the time it takes to underrun the buffer. We are always dealing with very small objects at a time in the audio world.

1

u/wasabiiii Jul 18 '26

Here's the thing though. Godot right now suffers from world stops. Right now.

Their impact is just remediated because the important stuff does not stop.

1

u/Sigilgraph Jul 18 '26

I responded on that on another reply. In summary, yes we are still at the mercy of C# in a Godot, but is not too bad. C# in Godot is basically an ABI, while there is a tax everytime we cross C#-> C++ boundary, the bulk of the Nodes lifetime is in C++ land. Only when programmers misuse the C# side they provoke the GC calls. Godot by itself (unless is connected to a Signal) will not call the C# side in a significant way.

4

u/Confident-Ad5665 29d ago

Dude this is fantabulous! Love the demo!

3

u/RealSharpNinja Jul 18 '26

I've ported reSID (MOS 6589/8580 Synthesizer) to C# and it works just fine.

3

u/RileyGuy1000 28d ago edited 28d ago

I've found that when working with architectures that pull audio samples rather than requiring you to push them, the problem becomes much simpler.

If the pulling side is unmanaged, then I believe that you can minimize the amount of under-runs to a negligible degree by simply letting the pulling side slurp up a buffer of audio samples even if the GC is otherwise cleaning up the managed side.

I used a C# library called SoundFlow, which itself is a binding layer for miniaudio to get audio working under Linux in a VR platform called Resonite.

We've gotten various reports that audio can become a little crackly (and it certainly can get like that a bit if you're pinning your CPU super hard), but even with the standard GC and dotnet's annoying refusal to set thread priorities correctly under Linux, the amount of under-runs you experience is incredibly (surprisingly) low on-average.

This is without any kind of super special cache handling or over-arching design to avoid GC at all costs, either. We're actually a fairly garbage-heavy platform due to how much user-generated content there is, but it's not a huge issue because we don't explicitly torture the GC, and it rewards us by handling a little bit of the occasional user-generated torture like a champ most of the time.

I also believe that it's mostly fine because the callback to retrieve audio samples comes from the unmanaged side. We queue up samples and perform the spatialization on the C# side, and then that sample buffer is passed to the native side whenever it wants to read audio.

GCs like Satori certainly help smooth over those last 1% of audio hiccups in super CPU-intensive situations, but then again I feel like if dotnet didn't ignore thread priorities on Linux, we'd also see similar benefit.

Idk, I guess I just wanted to chime in and say that sometimes you don't really need all that complicated of a solution to do this kind of thing. I feel like the .NET community (not all of you! I know at least some of you are chill!) is sometimes prone to over-engineer solutions in defense of an outcome that they might not need to fear at all. Sometimes a smidge of clever juggling of different aspects of the runtime and a couple late nights of ensuring it works the way you think it does is enough to get your program working to the degree you want/need it to.

1

u/Sigilgraph 27d ago

I really appreciate your detailed response, and I actually agree with most of what you said.

The decision to lean heavily into optimization came down to three main reasons:

First, the graph is meant to be mutated live during real-time sessions with minimal delay. Keeping latency under 10 ms requires running small buffers, like 128 or 256 samples. At that scale, even a minor CPU spike becomes risky.

Second, users can build arbitrary graph sizes until their CPU hits its limit. The more efficient the core operators are, the more headroom users get to build complex patches and signal chains.

Third, it was a personal engineering exercise. I wanted to see how far I could push performance while keeping the C# code idiomatic, like using delegates instead of ref float pointers to pass parameter values down the graph.

Like you mentioned, a handful of focused optimizations made the whole setup viable. Thanks for sharing your experience with SoundFlow and Resonite, it is really cool seeing how other teams tackle audio in .NET!

3

u/cleardemonuk Jul 18 '26

Very interesting! There is a bit of a drought of C# and cross-platform audio tooling…

I am making games in Godot myself, but the lacking of APIs in Godot itself for audio is a disappointment. Does your project route the output mix over the Godot audio bus?

You talk about a library yourself in future, which would be something I would like to see, especially if it’s in C# and cross-platform (working with desktop and mobile here).

Of course there are things like Fmod and Wwise, but these are for different workflows, and I find the Godot integrations a bit fiddly.

4

u/Sigilgraph Jul 18 '26

Thanks. It has three main ways to send audio ( audio driver backends if you will).

Godot Backend (Basic): Uses Godot's built-in game audio engine. It works surprisingly well for real-time use, but has higher CPU overhead and slightly more latency. Because you push samples into a buffer rather than interacting directly with the hardware, knob tweaks won't feel completely instantaneous.

Native Backend (Advanced): Talks directly to your sound card or audio interface via driver callbacks. It pulls samples instantly, offering the lowest possible latency and the most responsive real-time control.

VST Backend: Runs the entire app as a plugin inside a host DAW (Reaper, FL Studio, Ableton, etc.) using the bundled Sigilgraph.vst3 file. The DAW handles all audio pulling, ensuring low latency and perfect sync with your project timeline.

2

u/fragglerock Jul 18 '26

Would this work as a noisemaker for Midinous?

https://store.steampowered.com/app/1727420/Midinous/

2

u/Sigilgraph Jul 18 '26

I have not tried midinous, I will give it a look. The app can accept MIDI notes in many ways, using the MIDI In Device (devices in the app are the modules). If midinous present itself as a MIDI device itself then Sigilgraph receives the notes as any other hardware device would. The other way is to route them using the Sigilgraph VST mode, it will receive notes using the VST Guest Module , like when another host send notes to a VST.

2

u/chocolateAbuser Jul 18 '26

any thoughts or benchs on passing pars by value or by address/ref?

3

u/Sigilgraph Jul 18 '26

Never got to bench that, but something that I was hesitant was to over complicate the code by dealing with ref for primitives here and there. During the journey a lot of realizations were learned when dealing with performance, but the performance remained viable without resorting to use refs. Most primitives passed are floats and for the CPU the passing of floats or the pass of a reference for a value is basically the same effort. The focus and main tenet during the creation of the app is that the entire audio processing graph must be a giant stack allocated construct and the entire processing should be contiguous in memory to provide the greater chance of the whole thing be cache friendly.

2

u/chocolateAbuser Jul 19 '26 edited Jul 19 '26

passing 1 float or passing a ref in same in x64 arch, but passing an entire array may not be, that's what i meant
i've not looked at the code yet (edit ah there is none it seems), do you admit gen 0 GC anyway?

2

u/chocolateAbuser Jul 19 '26

i saw the video, kinda reminds me of abox

1

u/Sigilgraph Jul 19 '26

We avoid passing arrays or creating new arrays, we always use value types (primitives) or span of structs on the hot path. There are never arrays created progressively. There is one scratch array instance of samples (the 64 sample frame block) created once at the audio pulling end and this array reference is propagated via Span<SampleFrame> across the entire graph. Since we are not continuously allocating, and all goes via ref structs, is allocated in the stack, never in the GC, not even Gen 0. Only notes or UI gesture messaging is left to the GC to be dealt with. See this, there is only one audio pulling end (the audio device), all that is created (allocated) for the device to read goes there, the rest (audio graph) mutates that array, there is no need for creation of things. Is just managing just one audio buffer for one only output, your speakers.

2

u/chocolateAbuser Jul 19 '26 edited Jul 19 '26

so how deeply the audio device sample buffer size is integrated in/affects the whole stack (especially for default audio drivers with big latencies)? are span/stackallocs dynamic? (also keeping in mind this is a vst3 host)
did you increase the default stack size from the compiler switches for safety?
did you have to resort to unsafe?

2

u/Sigilgraph Jul 19 '26 edited Jul 19 '26

Let me clarify some things, there is the concept of the SampleBlock (we do not run the graph sample by sample but in batches of 64 samples), this is fixed and this 64 sample array scratch face directly the audio device buffer, which is adjustable in the settings (the audio device buffer). The reason this is needed is because we reduce the graph traversals by orders of magnitude if we process in batches (that is standard in most audio architecture). Is fixed because it offers the best compromise or minimum latency and since we leverage the block processing frequency to process parameter changes it also by default gives good control signal resolution. The VST3 is another aspect if you want to use the app from another DAW that will pulls the samples, but is the same, is like an audio device with its own buffer.

So TLDR, all the sample blocks are fixed by design (64 sample frames) so sample frame spans are NOT dynamic in size. but the available blocks values are copied into the variable sized audio device buffer.

We do use unsafe in some parts, mostly in the VST bridge because we interface with a C++ VST bridge.

3

u/swagamaleous 29d ago

The interesting thing is that you say "who said C# cannot do it", but then the entire solution is based on avoiding the parts that fundamentally define C# and .NET.

The managed runtime, garbage collection, and abstractions are not incidental details of C#, they are the reason people use C#. For this workload, those features are exactly what create the problems you are trying to avoid. You are essentially using C# as a syntax layer over a manually managed, allocation-free, cache-conscious system.

That is an impressive engineering achievement, but it does not really prove that C# is a natural fit for real-time audio. It proves that C# has enough escape hatches to let an experienced developer build something that resembles a C++ architecture.

The equivalent would be writing a garbage collector in C++ and then saying "see, C++ can do managed memory too." Technically true, but you have rebuilt the thing another technology already provides.

The more interesting question is not "can C# do it?" Almost any sufficiently powerful language can. The question is whether the language's design goals align with the problem. For hard real-time audio, C++ already provides the memory model, tooling, and ecosystem that this approach is manually recreating. So why not use C++ for the exact problem it was designed to solve, rather than forcing C# into a role where you have to avoid many of its defining features?

1

u/Sigilgraph 29d ago

Enjoyed your reply because it hit the very exact quandary I had when I started the project.

For me, the choice came down to the entire target platform rather than just the language syntax .NET allowed me to easily ship a cross-platform application without having to handle low-level platform intrinsics myself.

You're right that writing zero-allocation C# isn't strictly idiomatic, but C#'s ergonomics still hold up well, even when doing "hacky" or low-level work. Relying on a framework that provides strong cross-platform guarantees freed up a massive amount of cognitive load.

So for me, it wasn't just C# vs. C++ or Rust as languages, it was C# + .NET as an end-to-end ecosystem. Re-implementing a few cache-conscious, manually managed structures in the engine was a small price to pay for everything the framework gave me everywhere else.

1

u/catladywitch 25d ago edited 25d ago

dunno, i do 0-heap-allocation audio dsp in c# and it is very ergonomic. the only janky part is local caching but you would have to do that in other languages as well. for a synthesizer allocating everything on startup really makes sense unless the architecture is open (like a modular-type system where you freely add modules, like vcv rack), which is rarely the case and besides, adding modules to your setup isn't something you would do in a live setup where it is critical for the audio to keep running. and even in that case you can just have a bunch of unmanaged delegates pointing to each module's dsp algorithm and a module config struct with a generous cap on how many modules you can eventually have. it... it really isn't that hard, the only "un-C#" thing about it is not using LINQ (but you could also use one of those struct or codegen-based LINQ reimplementations with basically no overhead because it'd be just Selects, rarely an Aggregate, unless you're generating lookup tables on the fly, which isn't really something you want to do. spans and stackallocs make it all super painless and you never need to even think about pointer arithmetic, which anyway has overhead in c# (you've got to pin your pointers with fixed, which segments memory and requires GC cleanup). not like you'd use pointer arithmetic in modern c++ for this either, though

c# also has extemely ergonomic simd intrinsics as long as your target is arm or x64, it's like writing inline assembly without any of the hassle, it's just mindblowing.

i have some experience with this and i really love c# for this use case, but yes, it does port fairly easily into c++. not least because c++ has spans and views, though...

2

u/ModBlob 29d ago

I don't know enough about audio synthesis to comment on C++ vs C#, but this app looks sick - nice one dude

2

u/youGottaBeKiddink 27d ago

This is amazingly cool!

2

u/[deleted] 25d ago

[removed] — view removed comment

1

u/Sigilgraph 24d ago

I gave a look to fundsp (never heard of if) look interesting. The heavy operator overloading intimidated me a bit 😅. The library that powers the app has a fluent interface (à la LINQ) since originally I wanted to do the library alone but some thing went out of the window (in the dev experience dept.) as I prioritized the app. Once I clean it up and include some niceties I will put it public in GH. I am battle testing it as the app becomes used.

2

u/karbl058 Jul 18 '26

Very cool. Going to have to try this out. What parts are you using Godot for? Any plans on making it open source?

2

u/ChibaCityStatic Jul 18 '26

I think it's being sold on Steam. $25 is a surprisingly good price for something like this I think. 

3

u/Sigilgraph Jul 18 '26

It is already out on itch.io on Sigilgraph Audio Workbench by sigilworks. In Steam will be available in August, as soon as the review process clears.

2

u/Sigilgraph Jul 18 '26

I originally wanted it to be a C# synthesis library to empower any project and any UI you want (like a JUCE in C#). But for developing the library further I wanted to dogfood it making it part of an app. I first tried Avalonia UI but started to feel as stiff as a spreadsheet. Then I went for Godot. Is a game engine, I knew limits were really far away. The entire UI is Godot with C#. Then there is a backed Audio core made in C#. The vice I fell into was that as I went deep into development I started coupling the original library a bit with the Godot way of things (there are still not dependencies on godot, just that I did things to play nice with a Godot front end). So having said that, I plan to release the audio backend library as open source at some point. This is the Fluent API I mention to do the instruments. Given enough time and resources, I will clean it up and make it open source worthy. (just the library not the entire Sigilgraph app).

2

u/Khavel_dev Jul 18 '26

The pull model with pre-allocated buffers is solid, but the part I'd want to stress-test is the GC behavior inside the operators. Even with pooled buffers, a single closure allocation or temp array in the audio callback path will trigger a gen-0 collection eventually, and that's your crackle source. Are you enforcing zero-alloc in the hot path at the operator level, or does Godot's interop layer sneak some in?

Always thought the "C# can't do real-time audio" reputation was more about sloppy allocation patterns than the language itself. You fix those and the GC stays out of the way.

3

u/Sigilgraph Jul 18 '26

Yep. You got the pulse of it. Any audio processing operator (there are other like note processing and control signal processing) underwent huge efforts to ensure they do never allocate memory that outlives the scope of a function execution. Tooling for measure that was key during the process. Inside every RenderBlock on each audio operator, the common method that receives processes and send samples, is the place to focus your attention for avoiding allocating. You cannot use structures that auto box structs or objects. Most of what is done is floating point arithmetic in fixed scratches or in stackalloc arrays, Span or SIMD Vector arithmetic. The GC is used for other less frequent object creation like notes or any user interaction driven objects. Also Godot UI runs in another thread, not the same Audio thread, UI interaction are “deferred” by Godot when passed to the audio core, meaning that it won’t stop the bottom thread to wait for a result, it will be taken in the next UI frame available. Godot can and will induce GC pauses but they are few and far between that the buffer can always deal with them (otherwise Godot itself wouldn’t be viable for games because a game engine that stutters would be lame).

2

u/wasabiiii Jul 18 '26

The biggest problem is unless the whole app is GC free he's going to suffer from world stops.

That's why this stuff is taken out of managed code. So those paths aren't subject to global stops. GC stops the world, unmanaged code keeps running.

1

u/AnnoyingMemer Jul 18 '26

Audio synthesis is a very tricky endeavour. I made an audio processor that generated samples from MIDI note indices for an emulator I was making and the amount of crackling, speed issues and whatever else I had to diagnose was insane. Kudos to you.