audio
PipeWire: output and input volume and mute, device lists, per-app streams and Bluetooth codecs.
list {
items = mantle.audio:map(function(audio)
return audio.sinks
end),
key = function(sink) return tostring(sink.id) end,
each = function(sink)
return rect {
padding = 6,
background = sink.active and "#45475A" or "#1E1E2E",
on_click = function() mantle.audio:set_default_sink(sink.id) end,
children = { text { content = sink.name } },
}
end,
}
State
mantle.audio:get() returns AudioState, nil before the first push. A field marked ? may be absent.
mantle.audio’s payload.
| Field | Type | Description |
|---|---|---|
apps | AppStream[] | Apps playing or recording audio, excluding pid-less streams, notification sounds, meters and monitor captures. |
cards | AudioCard[] | ALSA and BlueZ cards with their profiles, ordered by device. A card makes sinks and sources only for its active profile: analog or HDMI on one Intel HDA card, a codec on a headset. |
sinks | AudioDevice[] | Every output device; the active one is the default. |
sources | AudioDevice[] | Every input device; the active one is the default. |
AppStream
One app’s playback or recording stream. Streams without a pid are left out.
| Field | Type | Description |
|---|---|---|
binary? | string | application.process.binary, e.g. "firefox". |
icon? | string | XDG icon name from application.icon-name, else media.icon-name, e.g. "firefox". |
id | integer | PipeWire node id, the first argument of set_app_volume, set_app_muted and set_app_target. |
media_name? | string | media.name, which tells one app’s streams apart, e.g. a browser tab’s "YouTube Music". Follows the stream as the client renames it. |
muted | boolean | Stream mute; false until volume is known. |
name? | string | application.name, if the client set one. |
pid | integer | Owning process id, from application.process.id. |
pinned_to? | integer | The sinks[].id or sources[].id the stream is pinned to: the device its target.object in the default metadata names, by node.name or object.serial, as set_app_target writes. nil when unset, or when it names no current device of the stream’s direction, such as an unplugged headset; WirePlumber then plays it on the default. |
process_name? | string | /proc/<pid>/comm, or nil if it was unreadable when the stream’s properties were read. |
recording | boolean | A capture stream, such as a call’s microphone, rather than playback. |
target? | integer | The sinks[].id a playback stream plays into, or the sources[].id a recording reads; nil while it is linked to neither. |
volume? | number | Stream volume in percent; nil until PipeWire reports the stream’s Props. |
AudioCard
One ALSA or BlueZ card and every profile it offers.
| Field | Type | Description |
|---|---|---|
active? | integer | index of the active profile; nil before PipeWire reports it. |
device | integer | PipeWire device id, the first argument of set_card_profile. |
mac? | string | A BlueZ card’s MAC address, from its bluez_card.* name with _ turned to :; joins mantle.bluetooth. nil on an ALSA card. |
name | string | device.description, e.g. "Built-in Audio", else device.nick, else device.name. |
ports | CardPort[] | Every port, unplugged ones included, ordered by index. A device’s port is its active one. |
profiles | CardProfile[] | Every profile, unavailable ones included, ordered by index. |
AudioChannel
One sink or source channel. position is nil if PipeWire omits or does not name its channel map.
| Field | Type | Description |
|---|---|---|
index | integer | Zero-based index for set_channel_volume. |
position? | string | Speaker position, e.g. "front_left" or "front_right". |
volume | number | Reported level in percent. Sink writes stop at 150; only the default output is corrected above it. |
AudioDevice
One sinks or sources entry.
| Field | Type | Description |
|---|---|---|
active | boolean | This is the default output or input; with no default known, or one not in this list, the lowest id is. |
balance? | number | -1.0 (left) to 1.0 (right); nil for mono, an unknown channel map, or before its first report. |
bus? | string | device.bus, e.g. "pci", "usb", "bluetooth". |
card? | integer | The cards[].device this comes from; nil for a virtual device. |
channels? | AudioChannel[] | Channel levels in percent, or nil before valid channel volumes arrive. Indices name the current channel order; read a fresh snapshot after a device changes. |
form_factor? | string | device.form-factor, e.g. "headset". |
icon? | string | device.icon-name theme name, e.g. "audio-card-analog". |
id | integer | PipeWire node id, the first argument of every device action; not reboot-stable. |
muted | boolean | false before its first report. |
name | string | node.description, e.g. "Built-in Audio Analog Stereo", else node.nick, else node.name. |
node_name | string | node.name, e.g. "alsa_output.pci-0000_00_1f.3.analog-stereo": stable across reboots, so a config keys its own labels on it. |
port? | string | The active card route’s port.type, e.g. "headphones", "hdmi", "mic". |
volume? | number | Volume in percent, the loudest channel; nil before its first report. A sink’s default is pulled back to 150 when another client sets it louder. |
CardPort
One entry of AudioCard.ports: a place a card routes sound, such as speakers or headphones.
| Field | Type | Description |
|---|---|---|
active | boolean | The card routes sound through it now: an active route of the active profile names it, and its type is that sink’s or source’s port. |
available | boolean | false when PipeWire reports the port unavailable, such as an unplugged jack. |
description | string | PipeWire’s description, e.g. "Headphones". |
direction | PortDirection | "output" for a sink’s port, "input" for a source’s. |
index | integer | Port index, the second argument of set_port. |
name | string | PipeWire’s untranslated id, e.g. "analog-output-headphones". |
profiles | integer[] | profiles[].index values the port works under. |
type? | string | port.type, e.g. "speaker", "headphones", "hdmi", "mic". |
CardProfile
One entry of AudioCard.profiles.
| Field | Type | Description |
|---|---|---|
available | boolean | false when PipeWire reports the profile unavailable, such as HDMI with no display plugged in. |
codec? | string | The Bluetooth codec the profile names, e.g. "AAC", "LDAC", "mSBC"; nil when it names none, as off and every ALSA profile. |
description | string | PipeWire’s description, e.g. "Digital Stereo (HDMI) Output". |
index | integer | Profile index, the second argument of set_card_profile. |
inputs | integer | Sources the profile makes; 0 for an output-only profile or off. |
name | string | PipeWire’s untranslated id, e.g. "output:hdmi-stereo" or "off". |
outputs | integer | Sinks the profile makes; 0 for an input-only profile or off. |
PortDirection
Whether a port plays or records.
| Value | Description |
|---|---|
"output" | A sink’s port, such as speakers. |
"input" | A source’s port, such as a microphone. |
Actions
Call each as mantle.audio:<action>(arguments...); ? marks an argument you may omit.
| Action | Arguments | Description |
|---|---|---|
set_volume | id: integer, volume: number | Sets a sinks[].id or sources[].id volume in percent, clamped to [0, 150] for a sink and [0, 100] for a source. It need not be the default. |
set_muted | id: integer, muted: boolean | Sets a sinks[].id or sources[].id mute. |
toggle_mute | id: integer | Toggles a sinks[].id or sources[].id mute. |
set_balance | id: integer, balance: number | Sets a sinks[].id or sources[].id balance, -1.0 (left) to 1.0 (right), clamped; the louder side keeps its level. |
set_default_sink | id: integer | Makes this sinks[].id the default output. WirePlumber saves the choice, so a volume change, re-plug or restart keeps it. |
set_default_source | id: integer | Makes this sources[].id the default input. WirePlumber saves the choice, so a volume change, re-plug or restart keeps it. |
set_channel_volume | id: integer, index: integer, volume: number | Sets one channels[] level of a sinks[].id or sources[].id in percent, clamped as set_volume. Other channels stay unchanged. |
set_app_volume | id: integer, volume: number | Sets an apps[].id stream’s volume in percent, clamped to [0, 100]. |
set_app_muted | id: integer, muted: boolean | Sets an apps[].id stream’s mute. |
set_app_target | id: integer, device?: integer | Moves an apps[].id stream to a sinks[].id, or a recording to a sources[].id; nil returns it to the default. WirePlumber keeps the choice for that app. |
set_card_profile | device: integer, index: integer | Switches a cards[].device to one of its profiles[].index. WirePlumber keeps the choice. |
set_port | card: integer, index: integer | Routes a cards[].device to one of its ports[].index, e.g. headphones instead of speakers. The port must work under the active profile. WirePlumber keeps the choice. |
set_virtual_devices | devices?: VirtualDevice[] | Replaces the declared virtual devices: each appears in sinks or sources while listed, and an empty list removes them. A reload keeps them until the next call; a replaced Renderer’s go. A change to any kind but a null sink restarts the other kinds’ devices, which drop out briefly. |
set_level_meters | ids?: integer[] | Meters these sinks[].id and sources[].id: mantle.audio_levels carries their peaks. Replaces the last list; an empty one stops every meter, and none runs before the first call. A reload keeps them until the next call; a replaced Renderer’s go. |
VirtualDevice
One set_virtual_devices entry.
| Field | Type | Description |
|---|---|---|
args? | table<string, any> | More arguments for the PipeWire module, or node properties for a null sink, as PipeWire’s docs spell them, e.g. { ["filter.graph"] = {...} }. Each top-level key replaces Mantle’s default for it, capture.props included. |
description? | string | node.description, the name a mixer shows. |
kind | VirtualKind | |
name | string | node.name, unique among the declared devices. |
VirtualKind
What makes a virtual device.
| Value | Description |
|---|---|
"null_sink" | A sink that plays nowhere, support.null-audio-sink; its monitor records what it receives. |
"combine" | One sink playing to several, libpipewire-module-combine-stream; args takes its stream.rules. |
"loopback" | A sink that plays into the default output, libpipewire-module-loopback. For a source instead, pass args with capture.props = {} and playback.props holding ["media.class"] = "Audio/Source" and ["node.name"]. |
"echo_cancel" | A microphone with the speakers’ echo removed, libpipewire-module-echo-cancel: the source is name, the sink it listens to name .. ".sink". |
"filter_chain" | A sink through a filter graph such as an equalizer, libpipewire-module-filter-chain; args takes its filter.graph. For a source, set capture.props and playback.props as for a loopback. |
Backend
PipeWire’s native API, on one thread shared with privacy.
| PipeWire object | Feeds |
|---|---|
Audio/Sink, Audio/Source nodes and the default metadata’s default.audio.sink/source | sinks, sources, their active, channels, volume, muted and balance; card is the node’s device.id |
default.configured.audio.sink/source in the default metadata | Written with default.audio.sink/source by set_default_sink/set_default_source, as wpctl set-default does. WirePlumber saves this key and picks the default from it on every rescan, so the choice survives a volume change or a re-plug; default.audio.* alone is overwritten by the next rescan |
Stream/Output/Audio, Stream/Input/Audio nodes | apps, minus the streams its field lists |
Link globals | an app’s target: the sink or source its links reach |
target.object in the default metadata, keyed by the stream’s id | An app’s pinned_to: the sink or source whose node.name or object.serial it names, nil when unset or when it names no current device of the stream’s direction. set_app_target writes the device’s node.name there, how WirePlumber moves a stream; nil deletes it. WirePlumber’s stream restore keeps the choice per app, so a stream that reconnects goes back to it, unless node.stream.restore-target is off. Whether that restore also writes target.object back, and so shows as pinned_to, is unverified; a choice made through another mixer that writes the key does show |
ALSA and bluez_card.* devices and their profiles | cards; a BlueZ card’s mac comes from its name, a profile’s codec from its name, else its English description |
Each ALSA and BlueZ device’s EnumRoute | cards[].ports; set_port writes a Route on the profile device a sink or source of the active profile uses, so a port outside the active profile is dropped |
support.null-audio-sink adapters and one pipewire -c child | set_virtual_devices: a null sink is an adapter the mixer creates on its own connection, so it goes with the Supervisor. Every other kind is a module in one child pipewire, whose conf the Supervisor writes under $XDG_RUNTIME_DIR and removes on exit. It restarts the child with backoff when it exits, and again when the mixer reconnects to a restarted PipeWire, which the child outlives with no modules; the child dies with the Supervisor. A module that fails to load is skipped, with PipeWire’s warning on the Supervisor’s stderr, and the others still load |
A capture stream per id set_level_meters names | audio_levels, pushed apart from this state |
Each ALSA and BlueZ device’s active Route | port, and a hardware sink’s or source’s volume, mute and balance, Bluetooth microphones included. The node’s Props only mirror the Route: they are read until it arrives, and a write before then is dropped. A virtual device has no Route and uses its node’s Props |
The first push waits until PipeWire has reported every object and its volume, so a machine with no
audio hardware still gets one push of empty lists. An unreachable PipeWire is logged and audio
stays nil until it comes up. A lost connection pushes empty lists, the same as no hardware. Either
way the Supervisor retries after 1 s, doubling to every 30 s, and the first push after it reconnects
is a full one.
Writes are not optimistic: state changes when PipeWire reports the new value. An action with no
target (a stale id, or a volume write or mute toggle before that device’s first report) is dropped
with a debug log. Volume, mute and balance writes go to the device named, which need not be the
default.
How do I…
Change volume on the scroll wheel, mute on middle click
A handler reads with :get(): it needs the value now, not a binding (input).
The default output is the active entry of sinks.
local function default_sink(audio)
for _, sink in ipairs(audio and audio.sinks or {}) do
if sink.active then
return sink
end
end
end
rect {
on_wheel = function(_, steps)
local sink = default_sink(mantle.audio:get())
if sink == nil or sink.volume == nil then
return
end
mantle.audio:set_volume(sink.id, math.max(0, math.min(100, sink.volume + steps * 5)))
end,
on_click = function(_, which)
local sink = default_sink(mantle.audio:get())
if which == "middle" and sink then
mantle.audio:toggle_mute(sink.id)
end
end,
children = {
text {
content = mantle.audio:map(function(audio)
local sink = default_sink(audio)
if sink == nil or sink.volume == nil then
return "--"
end
return sink.muted and "muted" or string.format("%d%%", math.floor(sink.volume + 0.5))
end, "--"),
},
},
}
Show an OSD when volume changes
on_change writes named state that a panel binds, and a
timer hides the panel again:
local osd_text = state("osd_text", "")
local osd_visible = state("osd_visible", false)
local hide_timer
local function default_sink(audio)
for _, sink in ipairs(audio.sinks) do
if sink.active then
return sink
end
end
end
mantle.audio:on_change(function(audio, previous)
local sink, before = default_sink(audio), previous and default_sink(previous)
if before == nil or sink == nil or sink.id ~= before.id or before.volume == nil or sink.volume == nil then
return -- the first push, or a new default, is learned state, not a change
end
if sink.volume == before.volume and sink.muted == before.muted then
return
end
osd_text:set(sink.muted and "Muted" or string.format("Volume %d%%", math.floor(sink.volume + 0.5)))
osd_visible:set(true)
if hide_timer then
hide_timer:cancel()
end
hide_timer = timer(2000, function() osd_visible:set(false) end)
end)
local osd = panel {
id = "osd",
layer = "overlay",
anchor = { bottom = true },
margin = { bottom = 80 },
visible = osd_visible,
padding = 12,
radius = 12,
background = "#1E1E2ECC",
child = text { content = osd_text, font_size = 16 },
}
return osd
See also: Volume OSD recipe.
Add a virtual sink or an echo-cancelled microphone
Declare the whole list once; each device shows up in sinks or sources like a hardware one, and
args passes PipeWire’s own module arguments through:
mantle.audio:set_virtual_devices({
{ kind = "null_sink", name = "stream_mix", description = "Stream mix" },
{ kind = "echo_cancel", name = "mic_aec", description = "Microphone (echo cancelled)" },
})
return text { content = "Virtual devices declared" }