Native PipeWire mixer (sinks / sources / per-app streams, default-node switching, volume + mute) for Phosphor-based desktop shells.
Owns the PipeWire main loop on a dedicated QThread, walks the pw_registry, and surfaces sinks / sources / streams as Qt + QML types. There is no UI. The shell decides how a volume mixer, OSD, or sound preferences page is rendered.
The library is non-visual (no Qt::Gui link) and single-process by design (the loop runs inside the same QGuiApplication that hosts the shell). All cross-thread plumbing is hidden, so consumers see a pure GUI-thread API backed by queued signals from the loop thread.
| Type | Role |
|---|---|
PipeWireConnection | Lifecycle owner. Holds the pw_main_loop thread, pw_context, pw_core. Exposes connected, daemonAvailable, defaultSinkName, defaultSourceName properties + error(QString), nodeAdded(PwNode*), nodeRemoved(PwNode*) signals. Routes per-node writes (writeVolumes, writeMuted) and WirePlumber metadata writes (setDefaultSink, setDefaultSource) onto the loop thread. |
PwNode | One audio node (sink, source, output stream, or input stream). Exposes id, name, nick, description, mediaClass, channelCount, volumes[] (linear amplitudes), muted, plus the async setters setVolume(qreal), setVolumes(QList<qreal>), setMuted(bool). Vended by PipeWireConnection; QML-uncreatable. |
PwNodeModel | Abstract QAbstractListModel filtering a connection's nodes by mediaClasses. 10 pinned role names (9 user roles + Qt::DisplayRole): node, id, name, nick, description, mediaClass, channelCount, volumes, muted, plus Qt::DisplayRole (display) that falls back through nick → description → name. |
PwSinkModel / PwSourceModel / PwStreamModel | Convenience subclasses with the common filters pre-applied. PwStreamModel lumps Stream/Output/Audio and Stream/Input/Audio together. |
PipeWireHost | QML singleton (Phosphor.Service.PipeWire 1.0) that owns the process-wide PipeWireConnection and forwards every observable signal. Auto-connects on construction, and exposes connectToDaemon(), disconnectFromDaemon(), and reconnect() for explicit lifecycle control. |
C++ shell composition root:
#include <PhosphorServicePipeWire/QmlRegistration.h>
int main(int argc, char** argv)
{
QGuiApplication app(argc, argv);
PhosphorServicePipeWire::registerQmlTypes();
// ... QQmlApplicationEngine engine; engine.load(shellUrl); etc.
return app.exec();
}QML consumer (volume slider per sink):
import Phosphor.Service.PipeWire 1.0
ColumnLayout {
Repeater {
model: PwSinkModel { connection: PipeWireHost.connection }
delegate: RowLayout {
required property var model
Label { text: model.display + (model.name === PipeWireHost.defaultSinkName ? " (default)" : "") }
Slider {
from: 0; to: 1
value: model.volumes.length > 0 ? model.volumes[0] : 0
onMoved: model.node.setVolume(value)
}
CheckBox {
checked: model.muted
text: "mute"
onToggled: model.node.setMuted(checked)
}
}
}
}The CLI in examples/phosphor-service-pipewire-cli/ is the canonical C++ usage example: see phosphor-service-pipewire-cli list sinks, set-volume <target> <pct>, set-default-sink <name>, mute <target>, default, and the rest of the subcommand surface.
pw_main_loop is not a Qt event loop. Every PipeWire API call must happen on the loop's thread. The library owns one QThread named PipeWireLoop whose run() body calls pw_main_loop_run directly (Qt's exec() never runs there). Work from the GUI thread reaches the loop via pw_loop_invoke (PipeWire's documented MT-safe dispatch). Events from the loop bounce back via QMetaObject::invokeMethod(..., Qt::QueuedConnection). Consumers never see a non-GUI-thread signal emission.PipeWireConnection.h, PwNode.h, PwNodeModel.h, and PipeWireHost.h are libpipewire-free, and every pw_* and spa_* type lives in src/. Consumers can target_link_libraries(... PhosphorServicePipeWire::PhosphorServicePipeWire) without dragging libpipewire-0.3 into their own include set.PwNode::volumes is per-channel linear amplitude [0.0, 1.0] (PipeWire's storage format). Cubic / perceptual curves for UI sliders live in a higher layer, and round-trips through the lib stay lossless. (U2 resolution.)error(QString) and flip connected to false. The shell decides on a backoff and calls connectToDaemon() again. PipeWireHost.reconnect() is the QML one-liner.default metadata global. Reads track default.audio.sink / default.audio.source (the runtime defaults). Writes target default.configured.audio.sink / default.configured.audio.source (the persistent setting WirePlumber promotes on the next reconcile). The connection silently no-ops the write if no WirePlumber-managed metadata is present. Shells can detect WirePlumber absence by issuing a setDefaultSink / setDefaultSource write and watching for the echo via defaultSinkNameChanged / defaultSourceNameChanged within a budget. The CLI's cmdSetDefault uses this exact pattern, surfacing "no WirePlumber default metadata observed" when the pre-write value was empty and stayed empty through the deadline (vs. "timed out waiting for WirePlumber echo" when the pre-write value was populated but the write didn't round-trip).LoopNode is passed as the data for its own pw_node_add_listener registration, so the param/info callbacks can recover both the node id and the owning Private* without walking the loop-side hashmap on every event.libpipewire-0.3 (PipeWire 1.0+ baseline, U1 resolution)defaultSinkName / defaultSourceName stay empty)daemonAvailable=false)Shipped. The PipeWireConnection lifecycle, the registry observer and PwNode, the sink / source / stream models, the volume + mute write path, default-sink / -source switching via WirePlumber metadata, QML registration with the PipeWireHost singleton, the CLI acceptance harness, and the smoke + role-pinning tests are all in place.