You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Part 4 of #1. Depends on #4. This is the risky one, and the satisfying one. Live playback moves onto the mixer, and the C comes out.
It is deliberately last: by the time you get here the mixer is already proven against the old engine's own renders, and the effects already exist in Go, so nothing regresses at the moment of deletion.
Problem
internal/audio is a hand-written cgo wrapper around a vendored 95,864-line miniaudio.h. It gives us four things: device output, file decoding, a node graph, and sound scheduling. Issues #2–#4 replaced the last three. Only the device is left — and for that, a binding already exists.
Before you start
This is the issue with the most genuinely new concepts. Worth an hour before writing code:
The device callback runs on a thread you don't control, at a hard deadline. Miss it and you get an audible dropout. It may not allocate, lock, log, or touch the filesystem.
…which is why the mixer does not run there. This is the key design decision of the whole refactor:
flowchart LR
M["mixer goroutine<br/>MixBlock()"] -- writes --> R[("ring buffer")]
R -- "callback only copies" --> D["malgo callback<br/>(device thread)"]
style M fill:#dff0e6,stroke:#2a7
style D fill:#ffe0dc,stroke:#c33
Loading
The red box does nothing but copy(). A GC pause in the green box costs you some of the ring's headroom, not a dropout. This is what makes writing a DAW in Go reasonable. Budget a few blocks of ring (≈60–80 ms) and the mixer can stall considerably without anyone hearing it.
A ring buffer (circular buffer) is a fixed array with a read index and a write index chasing each other. With exactly one producer and one consumer you need no locks — just atomics on the two indices. It is about 60 lines and worth writing yourself rather than pulling a dependency, because you will want to understand it when debugging a glitch.
malgo hands you []byte, not []float32. Set FormatF32 and convert. unsafe.Slice does it with no copy; binary does it with one. Start with whichever you find clearer.
Note malgo compiles miniaudio with MA_NO_DECODING -DMA_NO_ENGINE -DMA_NO_NODE_GRAPH etc. It is the device layer only, which is exactly what we want, and it's also why our bridge.c must go — two copies of miniaudio in one binary is a duplicate-symbol link error.
Proposed solution
// Package audio is the only place in CodaW that talks to the sound card.// It knows nothing about projects, tracks or effects — it moves float32// buffers to and from the device.package audio
// Player streams a mix function to the default output device.//// The mix function is called on an ordinary goroutine, never on the device// thread. The device callback only copies out of the ring buffer, so the// worst a slow mix block can do is eat into the ring — not glitch.typePlayerstruct {
dev*malgo.Devicering*ringstopchanstruct{}
}
// Fill is the mixer's side: fill buf with the next frames of interleaved// stereo float32. mix.Mixer.MixBlock has exactly this shape.typeFillfunc(buf []float32)
The device side, which is the whole of the "audio thread":
onSamples:=func(out, in []byte, frameCountuint32) {
f32:=unsafe.Slice((*float32)(unsafe.Pointer(&out[0])), len(out)/4)
p.ring.read(f32) // fills with silence if we've underrun
}
And the ring itself — written out because it's the part worth owning:
// ring is a single-producer, single-consumer circular buffer of float32.//// One goroutine writes (the mixer), one reads (the device callback). With// exactly one of each, atomic indices are enough — no mutex, which matters// because the reader must never block.typeringstruct {
buf []float32w, r atomic.Uint64// monotonic counts; index = n % len(buf)
}
// read fills dst. On underrun it writes silence for the remainder rather than// blocking or returning short — the device must always get its frames, and// a moment of silence is a far better failure than a stall.func (rb*ring) read(dst []float32) { /* ... */ }
That underrun comment is the kind of decision worth writing down: when something goes wrong on the audio path, output silence and keep going. Never block, never error, never retry.
Engine surgery
internal/engine currently holds the miniaudio graph. It becomes a thin coordinator:
Play/Stop/Seek set the mixer's frame position and start/stop the Player
Load/reload rebuilds the mix.Mixer from store.Get()
Do this as its own commit, separate from the malgo work, so the diff that adds the new device layer is readable on its own and the deletion is a clean git revert target if something surfaces later.
Why this solution
malgo is the only realistic option that does recording.oto is playback-only; PortAudio and SDL need a system-installed library, which breaks the download-a-binary install story. Recording is the next thing you want, so this choice is really being made now.
The ring buffer is what makes Go viable here — it moves the mixer off the deadline thread entirely.
internal/audio ends up ~150 lines, small enough that if malgo were ever abandoned, wrapping ma_device yourself is a two-day job rather than a rewrite.
CodaW plays back and renders; it is not a live instrument. A 60–80 ms buffer costs nothing here and buys a very large safety margin.
Alternatives considered
Keep bridge.c, switch ma_engine → ma_device. Genuinely viable — it keeps ma_decoder, so MP3/FLAC/OGG stay free, and it's ~40 lines of cgo you write once. Rejected because the stated goal is to stop maintaining C you don't understand, and this keeps the 95,864-line header in the repo forever. If you change your mind about that goal, this is the alternative to pick — everything else in this epic stays the same.
oto. Best-maintained Go audio output, used by Ebiten. Rejected: playback only, no capture. Recording is a hard requirement.
No ring buffer — mix directly in the callback. Fewer moving parts and lower latency. Rejected: it puts Go code with a GC on a hard-realtime deadline, and every future mistake (an accidental allocation, a log line) becomes an audible dropout instead of a slow block. The ring is ~60 lines that make a whole class of bugs inaudible.
A channel instead of a ring buffer. Very idiomatic Go and much less code. Rejected: channel receive can block and involves the scheduler, neither of which belongs in a device callback. Worth knowing this is almost right and only fails on that one point.
Risks, and what to do about them
Risk
Mitigation
Glitches under load
Make ring size and block size configurable; log underruns to stderr with a counter so "it crackles" becomes a number
Cross-compilation breaks
It shouldn't — still cgo, same four runners. Verify release.yml on a tag before announcing
Device rate ≠ project rate
Resample at the device edge only, or ask malgo for the project's rate and let it convert. Decide explicitly and write it down
It sounds different from the old engine
Expected and fine, but keep the old renders from #3 to compare against so you know why
Done when
codaw play and codaw watch run through mix.MixBlock on malgo
codaw render uses the same MixBlock — render and playback share one path
Seek, hot-reload, mute/solo, automation all still work
Underruns are counted and logged, not silent
miniaudio.h, bridge.c and the cgo wrappers are gone; grep -rn "import \"C\"" . returns nothing
go build works on macOS, Linux and Windows; release workflow passes on a test tag
Part 4 of #1. Depends on #4. This is the risky one, and the satisfying one. Live playback moves onto the mixer, and the C comes out.
It is deliberately last: by the time you get here the mixer is already proven against the old engine's own renders, and the effects already exist in Go, so nothing regresses at the moment of deletion.
Problem
internal/audiois a hand-written cgo wrapper around a vendored 95,864-lineminiaudio.h. It gives us four things: device output, file decoding, a node graph, and sound scheduling. Issues #2–#4 replaced the last three. Only the device is left — and for that, a binding already exists.Before you start
This is the issue with the most genuinely new concepts. Worth an hour before writing code:
The device callback runs on a thread you don't control, at a hard deadline. Miss it and you get an audible dropout. It may not allocate, lock, log, or touch the filesystem.
…which is why the mixer does not run there. This is the key design decision of the whole refactor:
flowchart LR M["mixer goroutine<br/>MixBlock()"] -- writes --> R[("ring buffer")] R -- "callback only copies" --> D["malgo callback<br/>(device thread)"] style M fill:#dff0e6,stroke:#2a7 style D fill:#ffe0dc,stroke:#c33The red box does nothing but
copy(). A GC pause in the green box costs you some of the ring's headroom, not a dropout. This is what makes writing a DAW in Go reasonable. Budget a few blocks of ring (≈60–80 ms) and the mixer can stall considerably without anyone hearing it.A ring buffer (circular buffer) is a fixed array with a read index and a write index chasing each other. With exactly one producer and one consumer you need no locks — just atomics on the two indices. It is about 60 lines and worth writing yourself rather than pulling a dependency, because you will want to understand it when debugging a glitch.
malgo hands you
[]byte, not[]float32. SetFormatF32and convert.unsafe.Slicedoes it with no copy;binarydoes it with one. Start with whichever you find clearer.Read
gen2brain/malgo's_examples/— playback is ~40 lines.Note malgo compiles miniaudio with
MA_NO_DECODING -DMA_NO_ENGINE -DMA_NO_NODE_GRAPHetc. It is the device layer only, which is exactly what we want, and it's also why ourbridge.cmust go — two copies of miniaudio in one binary is a duplicate-symbol link error.Proposed solution
The device side, which is the whole of the "audio thread":
The producer side, an ordinary goroutine:
And the ring itself — written out because it's the part worth owning:
That underrun comment is the kind of decision worth writing down: when something goes wrong on the audio path, output silence and keep going. Never block, never error, never retry.
Engine surgery
internal/enginecurrently holds the miniaudio graph. It becomes a thin coordinator:Play/Stop/Seekset the mixer's frame position and start/stop thePlayerLoad/reloadrebuilds themix.Mixerfromstore.Get()RendercallsMixBlockinto awav.Writerinstead ofReadFrames(this path already works from 2/5 — internal/mix: mixBlock(), the whole signal path in one function #3)automationLoopand its ticker are deleted — automation lives inMixBlocknowgraph.goand all offx.go's miniaudio wiring goesThe deletion
Do this as its own commit, separate from the malgo work, so the diff that adds the new device layer is readable on its own and the deletion is a clean
git reverttarget if something surfaces later.Why this solution
otois playback-only; PortAudio and SDL need a system-installed library, which breaks the download-a-binary install story. Recording is the next thing you want, so this choice is really being made now.internal/audioends up ~150 lines, small enough that if malgo were ever abandoned, wrappingma_deviceyourself is a two-day job rather than a rewrite.Alternatives considered
Keep
bridge.c, switchma_engine→ma_device. Genuinely viable — it keepsma_decoder, so MP3/FLAC/OGG stay free, and it's ~40 lines of cgo you write once. Rejected because the stated goal is to stop maintaining C you don't understand, and this keeps the 95,864-line header in the repo forever. If you change your mind about that goal, this is the alternative to pick — everything else in this epic stays the same.oto. Best-maintained Go audio output, used by Ebiten. Rejected: playback only, no capture. Recording is a hard requirement.No ring buffer — mix directly in the callback. Fewer moving parts and lower latency. Rejected: it puts Go code with a GC on a hard-realtime deadline, and every future mistake (an accidental allocation, a log line) becomes an audible dropout instead of a slow block. The ring is ~60 lines that make a whole class of bugs inaudible.
A channel instead of a ring buffer. Very idiomatic Go and much less code. Rejected: channel receive can block and involves the scheduler, neither of which belongs in a device callback. Worth knowing this is almost right and only fails on that one point.
Risks, and what to do about them
release.ymlon a tag before announcingDone when
codaw playandcodaw watchrun throughmix.MixBlockon malgocodaw renderuses the sameMixBlock— render and playback share one pathminiaudio.h,bridge.cand the cgo wrappers are gone;grep -rn "import \"C\"" .returns nothinggo buildworks on macOS, Linux and Windows; release workflow passes on a test tagautomationLoopand itstime.Tickerare deletedNot in this issue
Recording (needs
DeviceType: Duplex— follow-up), MP3/FLAC/OGG decode, meters overserve.