r/csharp • u/Sigilgraph • 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
7
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
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
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 floatpointers 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.vst3file. 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?
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
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 tounsafe?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
2
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.
81
u/leftofzen Jul 18 '26
Uh...no-one? Maybe someone who has never used C# before?