detached 4c97002

README.md RawHistoryFolder

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

source
1# purego-pipewire
2
3Go bindings for [PipeWire](https://pipewire.org/) via [purego](https://github.com/bnema/purego) instead of cgo.
4
5Linux only. `CGO_ENABLED=0`.
6
7> [!WARNING]
8> 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.
9
10## Install
11
12```sh
13go get github.com/bnema/purego-pipewire
14```
15
16## Architecture
17
18The binding layer is **generated** from the checked-in model in `gen/pipewire.json`. The generator produces:
19
20- **purego function bindings** for PipeWire init, loop, and stream entry points
21- **Outbound port interfaces** grouped by API surface (`InitAPI`, `LoopAPI`, `StreamOps`)
22- **A composite CAPI interface** that embeds the generated port groups
23- **Inbound port interfaces** for the handwritten core layer
24- **Public API wrappers** in `pipewire/` and SPA constants/helpers in `spa/`
25
26A 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.
27
28Mocks 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/`.
29
30## Usage
31
32### Runtime lifecycle
33
34```go
35import "github.com/bnema/purego-pipewire/pipewire"
36
37rt, err := pipewire.Init()
38if err != nil {
39	return err
40}
41defer rt.Deinit()
42
43loop, err := rt.NewMainLoop()
44if err != nil {
45	return err
46}
47defer loop.Destroy()
48
49go loop.Run()
50```
51
52### Player playback
53
54```go
55import "github.com/bnema/purego-pipewire/pipewire"
56
57rt, err := pipewire.Init()
58if err != nil {
59	return err
60}
61defer rt.Deinit()
62
63player, err := pipewire.NewPlayer(pipewire.PlayerConfig{
64	SampleRate:      48000,
65	Channels:        2,
66	FramesPerBuffer: 256,
67	SampleFormat:    pipewire.SampleFormatF32,
68	UnderrunPolicy:  pipewire.UnderrunFillSilence,
69}, pipewire.PlayerCallbacks{
70	Fill: func(buf *pipewire.PCMBuffer) (int, error) {
71		for ch := range buf.Samples {
72			for i := range buf.Samples[ch] {
73				buf.Samples[ch][i] = 0
74			}
75		}
76		return buf.Frames, nil
77	},
78})
79if err != nil {
80	return err
81}
82defer player.Close()
83
84if err := player.Start(); err != nil {
85	return err
86}
87```
88
89### Video source (screen-cast style producer)
90
91`VideoSource` publishes a PipeWire `Video/Source` node whose frames you write
92yourself into shared memory (memfd). It drives the graph, so there is no
93process callback to wait for, and a steady-state frame performs no heap
94allocation. DMA-BUF is not supported; buffers are always memfd.
95
96A `Loop`, the `Context` and `Core` created on it, and the `VideoSource` must be
97used from a single goroutine locked to one OS thread. You own the loop: poll
98`Loop.Fd()` together with your other file descriptors and call `Loop.Iterate()`
99when it is readable. Stream callbacks (`VideoHandler`) run inside `Iterate`.
100
101```go
102import (
103	"runtime"
104	"time"
105
106	"github.com/bnema/purego-pipewire/pipewire"
107)
108
109runtime.LockOSThread()
110defer runtime.UnlockOSThread()
111
112rt, err := pipewire.Init()
113if err != nil {
114	return err
115}
116defer rt.Deinit()
117
118loop, err := rt.NewLoop()
119if err != nil {
120	return err
121}
122defer loop.Destroy()
123ctx, err := rt.NewContext(loop)
124if err != nil {
125	return err
126}
127defer ctx.Destroy()
128core, err := ctx.Connect()
129if err != nil {
130	return err
131}
132defer core.Disconnect()
133
134src, err := core.NewVideoSource(pipewire.VideoConfig{
135	Name:    "my-screen",
136	Width:   1920,
137	Height:  1080,
138	MaxFPS:  60,
139	Formats: []pipewire.VideoFormat{pipewire.VideoFormatBGRx, pipewire.VideoFormatBGRA},
140}, handler) // handler implements pipewire.VideoHandler
141if err != nil {
142	return err
143}
144defer src.Destroy()
145
146// handler.StateChanged reports the node id once the stream is PAUSED or
147// STREAMING; hand it to the consumer (for example pipewiresrc path=<id>).
148
149// One frame: Dequeue, fill, Queue, then Trigger, in that order.
150if b := src.Dequeue(); b != nil { // nil: no buffer is free, skip the frame
151	// b.Data is a mapped memfd, b.Stride bytes per row.
152	copy(b.Data, frame)
153	src.Queue(b, seq, time.Since(start), false) // true flags the frame corrupted
154	src.Trigger()
155}
156```
157
158Buffers that PipeWire removes while you still hold them (renegotiation,
159`Resize`, `Destroy`, the consumer leaving) stay mapped until you hand them back
160with `Queue` or `Release`. `Resize` fails with `ErrVideoBorrowed` while a buffer
161is borrowed, and `Destroy` is best called when none is. Formats are BGRx and
162BGRA. A consumer that is not a plain `pipewiresrc` may need to declare
163`media.type=Video` (for example `pipewiresrc stream-properties=props,media.type=Video`)
164for WirePlumber to link it.
165
166### Testing with mocks
167
168The package ships pre-generated [Mockery v3](https://vektra.github.io/mockery/) mocks for the public interfaces in `github.com/bnema/purego-pipewire/pipewire/mocks`.
169
170**Step 1: Accept the interface in your code**
171
172```go
173package output
174
175import "github.com/bnema/purego-pipewire/pipewire"
176
177type Service struct {
178	player pipewire.Player
179}
180
181func New(player pipewire.Player) *Service {
182	return &Service{player: player}
183}
184
185func (s *Service) Start() error {
186	return s.player.Start()
187}
188```
189
190**Step 2: Mock it in tests**
191
192```go
193package output_test
194
195import (
196	"testing"
197
198	"github.com/bnema/purego-pipewire/pipewire/mocks"
199	"github.com/stretchr/testify/require"
200)
201
202func TestServiceStart(t *testing.T) {
203	mockPlayer := mocks.NewMockPlayer(t)
204	mockPlayer.EXPECT().Start().Return(nil)
205
206	svc := output.New(mockPlayer)
207	require.NoError(t, svc.Start())
208}
209```
210
211**Available mocks** in `github.com/bnema/purego-pipewire/pipewire/mocks`:
212
213| Mock | Interface | Key methods |
214|---|---|---|
215| `MockRuntime` | `pipewire.Runtime` | `Init`, `Deinit`, `NewMainLoop` |
216| `MockMainLoop` | `pipewire.MainLoop` | `Run`, `Destroy` |
217| `MockPlayer` | `pipewire.Player` | `Start`, `Pause`, `Stop`, `Close`, `State` |
218
219All mocks support `.EXPECT()` for type-safe expectations and `.On()`/`.Return()` for classic testify style.
220
221## Requirements
222
223- Go 1.26.1+
224- Linux with `libpipewire-0.3.so.0`
225- A running PipeWire daemon for integration tests and real playback
226- Override path: `PIPEWIRE_LIB_PATH=/custom/path/libpipewire-0.3.so.0`
227
228## Building
229
230```sh
231CGO_ENABLED=0 go build ./...
232CGO_ENABLED=0 go test ./...
233```
234
235Integration tests need a real PipeWire daemon:
236
237```sh
238go test -tags integration ./integration ./pipewire -v
239```
240
241The player integration suite checks public lifecycle transitions and proves that a live PipeWire process callback invokes the user-supplied `Fill` callback.
242
243## Regenerating bindings
244
245```sh
246go generate ./...
247```
248
249Or manually:
250
251```sh
252go run ./cmd/pipewiregen
253mockery
254```
255
256Maintainers refreshing the checked-in model can run:
257
258```sh
259./scripts/sync-headers.sh
260go generate ./...
261```
262
263## Project layout
264
265```text
266pipewire/            public API + mocks
267spa/                 public PipeWire and SPA constants/helpers
268internal/
269  ports/in/          inbound port interfaces
270  ports/out/         outbound port interfaces + mocks
271  core/              runtime and playback logic
272  capi/              purego bindings + bridge
273  loader/            dlopen libpipewire-0.3.so.0
274cmd/pipewiregen/     binding generator (parser, model, emitter, templates)
275gen/                 checked-in model data
276integration/         integration tests (require a real PipeWire daemon)
277```
278
279## License
280
281MIT

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