Skip to content

4/5 — internal/audio: malgo + ring buffer, and delete 96,550 lines of C #5

Description

@KevinGruber2001

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.

  • Read gen2brain/malgo's _examples/ — playback is ~40 lines.

  • 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.
type Player struct {
    dev  *malgo.Device
    ring *ring
    stop chan struct{}
}

// Fill is the mixer's side: fill buf with the next frames of interleaved
// stereo float32. mix.Mixer.MixBlock has exactly this shape.
type Fill func(buf []float32)

The device side, which is the whole of the "audio thread":

onSamples := func(out, in []byte, frameCount uint32) {
    f32 := unsafe.Slice((*float32)(unsafe.Pointer(&out[0])), len(out)/4)
    p.ring.read(f32) // fills with silence if we've underrun
}

The producer side, an ordinary goroutine:

func (p *Player) pump(fill Fill) {
    buf := make([]float32, blockFrames*channels) // allocated ONCE
    for {
        select {
        case <-p.stop:
            return
        default:
        }
        p.ring.waitForSpace(len(buf))
        fill(buf)          // <- mix.MixBlock
        p.ring.write(buf)
    }
}

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.
type ring struct {
    buf  []float32
    w, 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()
  • Render calls MixBlock into a wav.Writer instead of ReadFrames (this path already works from 2/5 — internal/mix: mixBlock(), the whole signal path in one function #3)
  • automationLoop and its ticker are deleted — automation lives in MixBlock now
  • Most of graph.go and all of fx.go's miniaudio wiring goes

The deletion

internal/audio/miniaudio.h    95,864
internal/audio/bridge.c           14
internal/audio/audio.go          103
internal/audio/sound.go          217
internal/audio/effect.go         291
internal/audio/offline.go         61
                              ──────
                              96,550

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
  • automationLoop and its time.Ticker are deleted

Not in this issue

Recording (needs DeviceType: Duplex — follow-up), MP3/FLAC/OGG decode, meters over serve.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions