# purego-pipewire Go bindings for [PipeWire](https://pipewire.org/) via [purego](https://github.com/bnema/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 ```sh 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](https://vektra.github.io/mockery/) and shipped in `pipewire/mocks/` and `internal/ports/out/mocks/`. ## Usage ### Runtime lifecycle ```go 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 ```go 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`. ```go 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=). // 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](https://vektra.github.io/mockery/) mocks for the public interfaces in `github.com/bnema/purego-pipewire/pipewire/mocks`. **Step 1: Accept the interface in your code** ```go 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** ```go 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 ```sh CGO_ENABLED=0 go build ./... CGO_ENABLED=0 go test ./... ``` Integration tests need a real PipeWire daemon: ```sh 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 ```sh go generate ./... ``` Or manually: ```sh go run ./cmd/pipewiregen mockery ``` Maintainers refreshing the checked-in model can run: ```sh ./scripts/sync-headers.sh go generate ./... ``` ## Project layout ```text 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