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