main 4c97002

Go PipeWire bindings via purego, without cgo.

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 in spa/

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 8

↑↓ moveenter openesc close