audio
PipeWire: output and input volume and mute, device lists, per-app streams and Bluetooth codecs.
list {
source = mantle.audio:map(function(audio)
return audio and audio.sinks or {}
end),
key = function(sink) return tostring(sink.id) end,
itemfn = function(sink)
return button {
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. |
balance? | number | Default output balance, -1.0 (left) to 1.0 (right); nil for mono or an unknown channel map. |
bluetooth | BluetoothCodecs[] | BlueZ audio devices PipeWire knows, with their codecs, ordered by device. |
muted | boolean | Default output mute; false with no default sink. |
sinks | AudioDevice[] | Every output device. |
source_muted | boolean | Default input (microphone) mute; false with no default source. |
source_volume? | number | Default input volume, 1.0 is 100%; set_source_volume caps at 1.0, another client may not. nil with no source or before its first volume report. |
sources | AudioDevice[] | Every input device. |
volume? | number | Default output volume, 0.0 to 1.5 (1.0 is 100%), loudest channel; louder writes by other clients are pulled back to 1.5. nil with no sink or before its first volume report. |
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 and set_app_muted. |
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. |
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. |
volume? | number | Stream volume, 1.0 is 100%; nil until PipeWire reports the stream’s Props. |
AudioDevice
One sinks or sources entry.
| Field | Type | Description |
|---|---|---|
active | boolean | This is the default output or input; with no default known, the lowest id is. |
bus? | string | device.bus, e.g. "pci", "usb", "bluetooth". |
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 argument of set_default_sink/set_default_source; not reboot-stable. |
name | string | node.description, e.g. "Built-in Audio Analog Stereo", else node.nick, else node.name. |
port? | string | The active card route’s port.type, e.g. "headphones", "hdmi", "mic". |
BluetoothCodecs
One BlueZ audio device’s codec choices, joined to mantle.bluetooth by MAC.
| Field | Type | Description |
|---|---|---|
active? | integer | index of the active profile; nil before PipeWire reports it or when it is not in codecs. |
codecs | CodecProfile[] | Available profiles that name a codec, ordered by index. |
device | integer | PipeWire device id, the first argument of set_bluetooth_profile. |
mac | string | MAC address from the bluez_card.* name, _ turned to :. |
CodecProfile
One entry of BluetoothCodecs::codecs.
| Field | Type | Description |
|---|---|---|
codec | string | Codec from the profile name, else its English description, e.g. "AAC", "LDAC", "mSBC". |
description | string | PipeWire’s description, e.g. "High Fidelity Playback (A2DP Sink, codec AAC)". |
index | integer | Profile index, the second argument of set_bluetooth_profile. |
Actions
Call each as mantle.audio:<action>(arguments...); ? marks an argument you may omit.
| Action | Arguments | Description |
|---|---|---|
set_volume | volume: number | Sets master output volume, clamped to [0.0, 1.5]. |
set_muted | muted: boolean | Sets master output mute. |
toggle_mute | Toggles master output mute. | |
set_balance | balance: number | Sets default output 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. |
set_default_source | id: integer | Makes this sources[].id the default input. |
set_source_volume | volume: number | Sets default input volume, clamped to [0.0, 1.0]. |
set_source_muted | muted: boolean | Sets default input mute. |
toggle_source_mute | Toggles default input mute. | |
set_app_volume | id: integer, volume: number | Sets an apps[].id stream’s volume, clamped to [0.0, 1.0]. |
set_app_muted | id: integer, muted: boolean | Sets an apps[].id stream’s mute. |
set_bluetooth_profile | device: integer, index: integer | Switches a bluetooth[].device to one of its codecs[].index. |
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, volume, muted, balance, source_volume, source_muted |
Stream/Output/Audio, Stream/Input/Audio nodes | apps, minus the streams its field lists |
bluez_card.* devices and their profiles | bluetooth |
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. Nothing reconnects: a PipeWire restart freezes audio at its last push until the
Supervisor restarts.
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).
button {
on_wheel = function(_, steps)
local audio = mantle.audio:get()
if audio == nil or audio.volume == nil then
return
end
mantle.audio:set_volume(math.max(0, math.min(1, audio.volume + steps * 0.05)))
end,
on_click = function(_, which)
if which == "middle" then
mantle.audio:toggle_mute()
end
end,
children = {
text {
content = mantle.audio:map(function(audio)
if audio == nil or audio.volume == nil then
return "--"
end
return audio.muted and "muted" or string.format("%d%%", math.floor(audio.volume * 100 + 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
mantle.audio:on_change(function(audio, previous)
if previous == nil or audio.volume == nil then
return -- the first push is learned state, not a change
end
if audio.volume == previous.volume and audio.muted == previous.muted then
return
end
osd_text:set(audio.muted and "Muted" or string.format("Volume %d%%", math.floor(audio.volume * 100 + 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 },
}
See also: Volume OSD recipe.