purego-pipewire
Go bindings for PipeWire via purego instead of cgo.
Linux only. CGO_ENABLED=0.
[!WARNING] This is early stage. The generated binding layer is in place, but the handwritten API is still focused on runtime lifecycle and float32 playback. API may change.
Install
go get github.com/bnema/purego-pipewire
Architecture
The binding layer is generated from the checked-in model in gen/pipewire.json. The generator produces:
- purego function bindings for PipeWire init, loop, and stream entry points
- Outbound port interfaces grouped by API surface (
InitAPI,LoopAPI,StreamOps) - A composite CAPI interface that embeds the generated port groups
- Inbound port interfaces for the handwritten core layer
- Public API wrappers in
pipewire/and SPA constants/helpers inspa/
A small hand-written core implements runtime init/deinit, player lifecycle, stream setup, and PCM buffer handoff for playback, plus the zero-allocation VideoSource (loop, context, core, memfd video buffers). The per-frame C calls of the video path use purego.Syscall6 instead of RegisterLibFunc wrappers, which allocate on every call.
Mocks for public interfaces and outbound ports are generated by Mockery v3 and shipped in pipewire/mocks/ and internal/ports/out/mocks/.
Usage
Runtime lifecycle
import "github.com/bnema/purego-pipewire/pipewire"
rt, err := pipewire.Init()
if err != nil {
return err
}
defer rt.Deinit()
loop, err := rt.NewMainLoop()
if err != nil {
return err
}
defer loop.Destroy()
go loop.Run()
Player playback
import "github.com/bnema/purego-pipewire/pipewire"
rt, err := pipewire.Init()
if err != nil {
return err
}
defer rt.Deinit()
player, err := pipewire.NewPlayer(pipewire.PlayerConfig{
SampleRate: 48000,
Channels: 2,
FramesPerBuffer: 256,
SampleFormat: pipewire.SampleFormatF32,
UnderrunPolicy: pipewire.UnderrunFillSilence,
}, pipewire.PlayerCallbacks{
Fill: func(buf *pipewire.PCMBuffer) (int, error) {
for ch := range buf.Samples {
for i := range buf.Samples[ch] {
buf.Samples[ch][i] = 0
}
}
return buf.Frames, nil
},
})
if err != nil {
return err
}
defer player.Close()
if err := player.Start(); err != nil {
return err
}
Video source (screen-cast style producer)
VideoSource publishes a PipeWire Video/Source node whose frames you write
yourself into shared memory (memfd). It drives the graph, so there is no
process callback to wait for, and a steady-state frame performs no heap
allocation. DMA-BUF is not supported; buffers are always memfd.
A Loop, the Context and Core created on it, and the VideoSource must be
used from a single goroutine locked to one OS thread. You own the loop: poll
Loop.Fd() together with your other file descriptors and call Loop.Iterate()
when it is readable. Stream callbacks (VideoHandler) run inside Iterate.
import (
"runtime"
"time"
"github.com/bnema/purego-pipewire/pipewire"
)
runtime.LockOSThread()
defer runtime.UnlockOSThread()
rt, err := pipewire.Init()
if err != nil {
return err
}
defer rt.Deinit()
loop, err := rt.NewLoop()
if err != nil {
return err
}
defer loop.Destroy()
ctx, err := rt.NewContext(loop)
if err != nil {
return err
}
defer ctx.Destroy()
core, err := ctx.Connect()
if err != nil {
return err
}
defer core.Disconnect()
src, err := core.NewVideoSource(pipewire.VideoConfig{
Name: "my-screen",
Width: 1920,
Height: 1080,
MaxFPS: 60,
Formats: []pipewire.VideoFormat{pipewire.VideoFormatBGRx, pipewire.VideoFormatBGRA},
}, handler) // handler implements pipewire.VideoHandler
if err != nil {
return err
}
defer src.Destroy()
// handler.StateChanged reports the node id once the stream is PAUSED or
// STREAMING; hand it to the consumer (for example pipewiresrc path=<id>).
// One frame: Dequeue, fill, Queue, then Trigger, in that order.
if b := src.Dequeue(); b != nil { // nil: no buffer is free, skip the frame
// b.Data is a mapped memfd, b.Stride bytes per row.
copy(b.Data, frame)
src.Queue(b, seq, time.Since(start), false) // true flags the frame corrupted
src.Trigger()
}
Buffers that PipeWire removes while you still hold them (renegotiation,
Resize, Destroy, the consumer leaving) stay mapped until you hand them back
with Queue or Release. Resize fails with ErrVideoBorrowed while a buffer
is borrowed, and Destroy is best called when none is. Formats are BGRx and
BGRA. A consumer that is not a plain pipewiresrc may need to declare
media.type=Video (for example pipewiresrc stream-properties=props,media.type=Video)
for WirePlumber to link it.
Testing with mocks
The package ships pre-generated Mockery v3 mocks for the public interfaces in github.com/bnema/purego-pipewire/pipewire/mocks.
Step 1: Accept the interface in your code
package output
import "github.com/bnema/purego-pipewire/pipewire"
type Service struct {
player pipewire.Player
}
func New(player pipewire.Player) *Service {
return &Service{player: player}
}
func (s *Service) Start() error {
return s.player.Start()
}
Step 2: Mock it in tests
package output_test
import (
"testing"
"github.com/bnema/purego-pipewire/pipewire/mocks"
"github.com/stretchr/testify/require"
)
func TestServiceStart(t *testing.T) {
mockPlayer := mocks.NewMockPlayer(t)
mockPlayer.EXPECT().Start().Return(nil)
svc := output.New(mockPlayer)
require.NoError(t, svc.Start())
}
Available mocks in github.com/bnema/purego-pipewire/pipewire/mocks:
| Mock | Interface | Key methods |
|---|---|---|
MockRuntime |
pipewire.Runtime |
Init, Deinit, NewMainLoop |
MockMainLoop |
pipewire.MainLoop |
Run, Destroy |
MockPlayer |
pipewire.Player |
Start, Pause, Stop, Close, State |
All mocks support .EXPECT() for type-safe expectations and .On()/.Return() for classic testify style.
Requirements
- Go 1.26.1+
- Linux with
libpipewire-0.3.so.0 - A running PipeWire daemon for integration tests and real playback
- Override path:
PIPEWIRE_LIB_PATH=/custom/path/libpipewire-0.3.so.0
Building
CGO_ENABLED=0 go build ./...
CGO_ENABLED=0 go test ./...
Integration tests need a real PipeWire daemon:
go test -tags integration ./integration ./pipewire -v
The player integration suite checks public lifecycle transitions and proves that a live PipeWire process callback invokes the user-supplied Fill callback.
Regenerating bindings
go generate ./...
Or manually:
go run ./cmd/pipewiregen
mockery
Maintainers refreshing the checked-in model can run:
./scripts/sync-headers.sh
go generate ./...
Project layout
pipewire/ public API + mocks
spa/ public PipeWire and SPA constants/helpers
internal/
ports/in/ inbound port interfaces
ports/out/ outbound port interfaces + mocks
core/ runtime and playback logic
capi/ purego bindings + bridge
loader/ dlopen libpipewire-0.3.so.0
cmd/pipewiregen/ binding generator (parser, model, emitter, templates)
gen/ checked-in model data
integration/ integration tests (require a real PipeWire daemon)
License
MIT