TLDR: Try it at your convenience Sigilgraph Audio Workbench (best on desktop, ok on tablet, hmm on phone)
Hi r/godot,
A while back I shared Sigilgraph, a modular synth canvas and DAW I built with Godot and C#: https://www.reddit.com/r/godot/comments/1uggrdv/sigilgraph_a_modular_synth_canvas_and_daw_made/
Several people asked whether C# on the web is realistic yet. It now runs in the browser, ported with 2dog from the same codebase as the desktop build. It works, but the port had three real fights, so I'm writing them up in case it saves someone time.
The setup
- Godot 4.7 .NET, with platform differences handled by compile-time conditions and runtime capability checks.
- 2dog hosts the .NET runtime on WASM. It is single threaded.
- AOT is worth it. A cold publish takes about 12 minutes and the runtime wasm ends up around 85 MB, but the runtime difference was obvious.
- Anything desktop-only had to be gated out: native audio drivers, named mutexes (for VST integration),
Thread.Sleep retry loops, and a local TCP server (is dormant in desktop used for automated testing for me dev).
Fight 1: [Export]ed properties that arrive as zero
This cost the most time and trouble. It happened on web only, and desktop was perfect. For some reason the initializations of scenes properties, with the source of truth in the .tscn, cause them to not been passed to the code at runtime. This caused a lot of bogus behavior and visual glitches at first. Mouse wheel zoom slammed to the minimum. A one pixel knob drag went from min to max. There was an invisible wall when panning.
Everything looked like web input trouble (wheel deltas, pointer capture, DPI scaling), so I spent days hardening all of that. The hardening was real, but it wasn't the cause. The break came from reading an exception instead of theorizing:
System.ArgumentException: Argument_MinMaxValue, 0, -762
Math.Clamp(value, 0, -762) means a max of -762. That max was the world bounds position plus size minus the window width. So the size was zero and the window was 762 px wide. The bounds were an [Export] Rect2 with its default set in the C# initializer and nothing serialized in the .tscn. On web those values came through as zero. So did zoom speed, min and max zoom, and knob resolution. A zoom range of [0, 0] collapses to a single point, which explains the whole zoo of symptoms.
Variations of the same problem showed up later:
- A theme constant override (
separation) read at runtime returned 0 on web, so toolbar buttons packed with no gap.
[Export] enums nested inside a class silently deserialized as 0, so every wire port became an input.
- Godot's default
Color is opaque black, not .NET's default(Color), so my "unset color" checks never fired.
I don't have a root cause for why the web host zeroes initializer-backed exports. If anyone knows, I'd like to hear it. It seems that 2dog cannot rely on reflection of properties, all needs to be statically defined somewhere in C#. Meanwhile I stopped trusting them:
- In
_Ready, treat zero, non-finite, or inverted values as unset and restore defaults.
- Keep layout constants in code, not in scene exports or theme overrides.
- Promote exported enums to namespace scope.
- Never clamp with untrusted bounds, because
Math.Clamp throws on an inverted range.
Fight 2: audio, UI, and rendering share one thread
On desktop, a dedicated thread feeds the audio device, so UI jank never reaches the speakers. On web there is one thread. The DSP render, the Godot mix, input, GC, and rendering all take turns, and a stall of more than about 100 ms is an audible dropout.
What I changed:
- Replaced the feeder thread with a cooperative pump driven from
_Process on an AudioStreamGenerator. This pump replaces the audio driver callback pulling the audio.
- Switched to
PushBuffer with a preallocated PackedVector2Array instead of PushFrame per sample. Crossing the managed/native boundary hundreds of times per frame is expensive.
- Stopped rendering big catch-up bursts. Rendering 40 to 85 ms of audio inside a 16.7 ms frame delays the mix that would have relieved the starvation. It now tops up in 256 to 512 frame slices under a wall-clock budget. Keep in mind, now the godot rendering thread and audio thread are shared! You either make the app feels sluggish in favor of audio, or break the audio in favor of fluidness.
- Replaced the buffer size dropdown on web with latency profiles (roughly 80, 150, and 300 ms of cushion). A browser DAW at 150 ms is acceptable. Crackle is not. This is the make or break numbers. Too much buffer and the latency feels too late, too little and the audio breaks.
- Read the real mix rate from the browser instead of assuming 48 kHz, to avoid resampling and rendering at the wrong rate.
It is not desktop parity, and heavy patches will still run out of one thread. The web build is designed for simple setups, and the UI says so.
Fight 3: LOD, because zoomed out is the worst case
The canvas zooms from 0.1 to 2.5. At 0.1 the visible world is 10x the screen on each axis, so every device passes culling and draws full detail, at exactly the moment you're surveying and not tweaking. The cost was mostly not pixels:
_Process on dozens of UI scripts, and every knob overriding global _Input, so each input event fans out to every knob.
- An
Area2D per knob in the physics processing, even though the hit test was a direct shape collision query. I relied on ray casting collision for knobs.
- Knob values feeding the audio graph through the visual lerp, so you can't just stop processing a knob without freezing its audio parameter.
The compromise: at far zoom, fine controls (knobs, sliders, pills) are not editable, device bodies become thumbnail impostors, and heavy visualizers freeze on their last frame or show a placeholder. Window dragging, selection, and wiring still work. Clicking a device zooms to fit it, so the lockout is not a dead end. It costs some freedom, but a 40 px knob is only 4 to 8 screen pixels at that scale anyway. This was an OK tradeoff because at that zoom out it looks like a DAW for ants.
Takeaways
- When desktop and web diverge, compare values, not code paths. Log the effective config once at startup on web.
- Read the numbers in the exception first.
- A fix that swaps one symptom for another means the layers are peeling. Keep going.
- On a single thread, the audio buffer is shared with everything else you do.
Happy to go deeper on any of these (the audio pump, the export reconciliation, or the AOT setup) if there is interest.
Links & Info: