PipeWire.NET.Media
0.3.0-dev2
dotnet add package PipeWire.NET.Media --version 0.3.0-dev2
NuGet\Install-Package PipeWire.NET.Media -Version 0.3.0-dev2
<PackageReference Include="PipeWire.NET.Media" Version="0.3.0-dev2" />
<PackageVersion Include="PipeWire.NET.Media" Version="0.3.0-dev2" />
<PackageReference Include="PipeWire.NET.Media" />
paket add PipeWire.NET.Media --version 0.3.0-dev2
#r "nuget: PipeWire.NET.Media, 0.3.0-dev2"
#:package PipeWire.NET.Media@0.3.0-dev2
#addin nuget:?package=PipeWire.NET.Media&version=0.3.0-dev2&prerelease
#tool nuget:?package=PipeWire.NET.Media&version=0.3.0-dev2&prerelease
PipeWire.NET
.NET bindings for PipeWire, the audio and video graph on modern Linux desktops. Read and route the graph, control volumes and devices, capture and publish audio and video, run DSP inside the graph, or publish virtual devices of your own.
Alpha. Early and working, but largely untested in the wild and rough in places. Try it and file issues; expect breaking changes before 1.0.
Install
dotnet add package PipeWire.NET # graph, control, serving, SPA pods
dotnet add package PipeWire.NET.Media # audio and video streams
Hello, graph
await using var ctx = new PipeWireContext();
await ctx.StartAsync();
await using var registry = new PipeWireRegistry(ctx);
await registry.WaitForInitialEnumerationAsync();
foreach (PipeWireNode node in registry.Nodes)
Console.WriteLine($"[{node.NodeId}] {node.Description} ({node.MediaClass})");
A PipeWireContext is one connection and one event loop thread; a PipeWireRegistry keeps a live
snapshot of what the daemon has.
New here? docs/quickstart.md takes you from an empty project to reading the graph, changing a volume, capturing audio and publishing a node, picking up the concepts on the way. docs/pipewire-concepts.md is the model on its own.
What you can do
| Start here | |
|---|---|
| List what exists, watch it change | PipeWireRegistry, below |
| Set a volume, switch a card profile, read session defaults | one object at a time |
| Capture or publish audio and video | streams, docs/streaming.md |
| Run DSP in the graph, publish a virtual device | docs/serving.md |
| Work out which type you want | docs/choosing-a-type.md |
The rule the API turns on: are you reading somebody else's object, or being one? Those are different types with different suffixes, and picking wrong compiles and then does nothing you wanted. docs/choosing-a-type.md is one table per case.
Requirements
| OS | Linux (x64 / arm64) |
| Runtime | libpipewire-0.3.so.0 (ships with any PipeWire install) |
| PipeWire | Bindings generated against 1.6.8; see version policy |
| Daemon | A running PipeWire daemon plus a session manager such as WirePlumber |
| .NET | .NET 10, or .NET 11 (preview) |
sudo apt-get install pipewire wireplumber # Debian / Ubuntu
sudo dnf install pipewire wireplumber # Fedora
sudo pacman -S pipewire wireplumber # Arch
Both packages are Native AOT compatible, and CI publishes and runs the sample as AOT on every build.
Reading the graph
registry.Current is an immutable snapshot: nodes, ports, links, devices, clients, metadata. Read
it for point queries, or consume WatchAsync for one snapshot per change.
await foreach (PipeWireGraphSnapshot graph in registry.WatchAsync(cancellationToken))
{
foreach (PipeWireNode node in graph.Nodes)
Console.WriteLine($"[{graph.Version}] [{node.NodeId}] {node.Description}");
}
Connecting over a portal fd
A sandboxed client may not see the daemon socket. xdg-desktop-portal's ScreenCast
OpenPipeWireRemote returns a socket fd already connected to the daemon, restricted to what the
user granted. The handle is borrowed: the library duplicates it close-on-exec, as PipeWire does
itself, and yours stays open.
StartAsync takes a SafeHandle, so wrap the descriptor the reply carries:
using Microsoft.Win32.SafeHandles;
// rawFd: the descriptor from the OpenPipeWireRemote reply's UnixFdList
using var handle = new SafeFileHandle((IntPtr)rawFd, ownsHandle: true);
await using var ctx = new PipeWireContext();
await ctx.StartAsync(handle, cancellationToken);
Acting on one object
Bind a proxy to act on one object: volumes and mutes on nodes, profiles and routes on devices,
permissions on clients, defaults and clock on metadata. A proxy is used with await using and
reports readiness through ReadyAsync.
await using PipeWireNodeProxy node = registry.BindNode(nodeId);
await node.ReadyAsync(cancellationToken);
float? volume = await node.GetVolumeAsync(cancellationToken);
bool? muted = await node.GetMutedAsync(cancellationToken);
Console.WriteLine($"volume {volume}, muted {muted}");
await node.SetVolumeAsync(0.5f, cancellationToken);
await node.SetMutedAsync(false, cancellationToken);
Device routes and profiles work the same way through registry.BindDevice:
await using PipeWireDeviceProxy device = registry.BindDevice(deviceId);
await device.ReadyAsync(cancellationToken);
foreach (SpaObject route in await device.EnumerateRoutesAsync(cancellationToken))
Console.WriteLine(route);
The session defaults - default sink and source, graph clock rate and quantum - live in the default
metadata store, which is absent on a session with no session manager running:
PipeWireMetadataProxy? store = registry.BindMetadata("default");
if (store is not null)
{
await using (store)
{
await store.ReadyAsync(cancellationToken);
Console.WriteLine($"sink: {store.DefaultAudioSink?.NameValue}");
Console.WriteLine($"source: {store.DefaultAudioSource?.NameValue}");
Console.WriteLine($"clock: {store.ClockRate} Hz / quantum {store.ClockQuantum}");
}
}
Linking
// Output port first, input port second.
PipeWireLink link = await registry.CreateLink(outputPortId, inputPortId)
.ExecuteAsync(cancellationToken);
await registry.RemoveLinkAsync(link.LinkId, cancellationToken);
WithPassive() makes a link follow the graph without forcing the nodes active. Most applications
never call this: routing is the session manager's job, as
docs/pipewire-concepts.md explains.
Streaming
Capture reads from a node; output publishes one. Both take a target node id or name, or let the session manager choose.
await using var capture = new PipeWireAudioCapture(ctx);
capture.FrameReady += (_, frame) =>
Console.WriteLine($"{frame.SampleRate} Hz {frame.Channels}ch {frame.Format}");
capture.Connect();
await using var output = new PipeWireAudioOutput(ctx, "sample_synth");
output.FillSamples += (_, samples, sampleRate, channels, format) =>
{
// The byte count written; 0 publishes silence.
return WriteTone(samples, sampleRate, channels);
};
output.Connect();
Video is the same shape:
await using var camera = new PipeWireVideoCapture(ctx);
camera.FrameReady += (_, frame) =>
Console.WriteLine($"{frame.Width}x{frame.Height} {frame.Format}");
camera.Connect(); // auto-selects the default video source
await using var screen = new PipeWireVideoOutput(ctx, "sample_screen", 1280, 720);
screen.FillFrame += (_, pixels, stride, width, height, format) =>
{
Render(pixels, stride, width, height);
return true; // false publishes nothing this cycle
};
screen.Connect();
Frames are ref structs delivered on the loop thread and valid only for the handler; Clone what
must outlive it. They carry the pixels or samples, the negotiated format, the backing memory and
four timestamps. Fill callbacks write straight into the daemon's buffer, so there is no intermediate
copy, and capture can take DMA-BUF buffers for GPU sources.
docs/streaming.md covers timestamps and A/V sync, DMA-BUF, explicit sync and multi-GPU device negotiation.
Screen capture on Wayland
This library does not talk to Wayland. Screen capture goes through the
org.freedesktop.portal.ScreenCast portal, which returns a node id after the user grants
permission; pass it to PipeWireVideoCapture.Connect(nodeId) and it behaves like any other source.
Serving
Publishing something other clients use - a virtual sink, a DSP node, a device, a metadata store - is its own guide: docs/serving.md. The shortest example is a sink other applications can play into, which needs no callbacks at all:
PipeWireNode sink = await registry.CreateVirtualSinkAsync("My mix", cancellationToken: cancellationToken);
SPA pods
Everything configurable on a node, port or device is a parameter, and every parameter is a SPA pod.
SpaPod parses and writes the value model (SpaInt, SpaString, SpaObject, SpaChoice and the
rest); docs/parameters-and-pods.md covers reading a device's routes,
what a SpaChoice means during negotiation, and writing one back.
byte[] bytes = SpaPod.ToBytes(new SpaInt(48000));
if (SpaPod.TryParse(bytes, out SpaValue? value) && value is SpaInt rate)
Console.WriteLine(rate.Value);
Sample app
samples/PipeWire.NET.SampleConsole is a small CLI over both packages, and the quickest way to see
whether your machine is set up.
dotnet run --project samples/PipeWire.NET.SampleConsole -- list
dotnet run --project samples/PipeWire.NET.SampleConsole -- monitor
dotnet run --project samples/PipeWire.NET.SampleConsole -- volume alsa_output.pci --set 0.5
dotnet run --project samples/PipeWire.NET.SampleConsole -- defaults
dotnet run --project samples/PipeWire.NET.SampleConsole -- capture-audio --seconds 5
dotnet run --project samples/PipeWire.NET.SampleConsole -- capture-video --seconds 5
dotnet run --project samples/PipeWire.NET.SampleConsole -- filter --seconds 8
dotnet run --project samples/PipeWire.NET.SampleConsole -- serve
filter plays a quiet generated tone through a gain node into the default sink; serve publishes a
virtual source until Ctrl+C.
Documentation
Start here
| quickstart.md | empty project to working program, with the concepts as you need them |
| pipewire-concepts.md | the graph model in ten minutes, if PipeWire is new to you |
| choosing-a-type.md | which type to reach for, per task, with upstream's name for each |
| troubleshooting.md | symptoms and what they usually mean |
Going further
| threading-and-lifetimes.md | callback threading, the realtime contract, disposal, lingering objects |
| parameters-and-pods.md | reading and writing parameters: routes, profiles, formats |
| streaming.md | frame timing, A/V sync, DMA-BUF, explicit sync |
| serving.md | publishing nodes, devices, filters and metadata |
| pipewire-roles.md | consuming versus being an object, and what serving costs |
| running-tests.md | the test categories and what each one needs |
Upstream's own Overview and API tutorial are the reference for the C API these bindings cover.
How it is built
The low-level bindings in src/PipeWire.NET/generated/ are produced by
ClangSharpPInvokeGenerator from the installed PipeWire
headers and committed, so consumers never run the generator. Four passes produce them: the ABI
(pipewire.rsp) and one per library whose macros are needed (pipewire-constants.rsp, libc.rsp,
libdrm.rsp). The hand-written types on top - context, registry, proxies, providers, streams,
filters, the SPA pod codec - are ordinary C#.
To regenerate after a PipeWire version bump, on Linux with libpipewire-0.3-dev and libclang-dev:
dotnet tool install --global ClangSharpPInvokeGenerator --version 21.1.8.3
bash generate/generate.sh
CI regenerates on every build and fails if the committed output drifts.
PipeWire version policy
The bindings are generated from the headers of one specific release, recorded in
generate/HEADER-VERSION and enforced by the generator. That release is what the committed bindings
describe and what the gating test job runs against.
Older daemons are not rejected and should mostly work: the library binds a small, long-stable part of the protocol. What an older daemon can lack is usually a whole interface, and binding one that is not there fails at the bind rather than silently misbehaving. If you need a specific older release supported, open an issue with the version.
Testing
dotnet test --filter "TestCategory!=Integration" # pure logic, runs anywhere
dotnet test --filter "TestCategory=Integration" # needs a running daemon
Integration tests run against a live daemon, some of them driving real producers through GStreamer. docs/running-tests.md lists every category and what it needs.
License
MIT, see LICENSE. PipeWire is MIT licensed and is not redistributed here.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. net11.0 is compatible. |
-
net10.0
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.12)
- PipeWire.NET (>= 0.3.0-dev2)
-
net11.0
- PipeWire.NET (>= 0.3.0-dev2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.3.0-dev2 | 51 | 9/23/2026 |
| 0.3.0-dev1 | 52 | 9/23/2026 |