Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

FieldTypeDescription
appsAppStream[]Apps playing or recording audio, excluding pid-less streams, notification sounds, meters and monitor captures.
balance?numberDefault output balance, -1.0 (left) to 1.0 (right); nil for mono or an unknown channel map.
bluetoothBluetoothCodecs[]BlueZ audio devices PipeWire knows, with their codecs, ordered by device.
mutedbooleanDefault output mute; false with no default sink.
sinksAudioDevice[]Every output device.
source_mutedbooleanDefault input (microphone) mute; false with no default source.
source_volume?numberDefault 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.
sourcesAudioDevice[]Every input device.
volume?numberDefault 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.

FieldTypeDescription
binary?stringapplication.process.binary, e.g. "firefox".
icon?stringXDG icon name from application.icon-name, else media.icon-name, e.g. "firefox".
idintegerPipeWire node id, the first argument of set_app_volume and set_app_muted.
mutedbooleanStream mute; false until volume is known.
name?stringapplication.name, if the client set one.
pidintegerOwning 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.
recordingbooleanA capture stream, such as a call’s microphone, rather than playback.
volume?numberStream volume, 1.0 is 100%; nil until PipeWire reports the stream’s Props.

AudioDevice

One sinks or sources entry.

FieldTypeDescription
activebooleanThis is the default output or input; with no default known, the lowest id is.
bus?stringdevice.bus, e.g. "pci", "usb", "bluetooth".
form_factor?stringdevice.form-factor, e.g. "headset".
icon?stringdevice.icon-name theme name, e.g. "audio-card-analog".
idintegerPipeWire node id, the argument of set_default_sink/set_default_source; not reboot-stable.
namestringnode.description, e.g. "Built-in Audio Analog Stereo", else node.nick, else node.name.
port?stringThe 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.

FieldTypeDescription
active?integerindex of the active profile; nil before PipeWire reports it or when it is not in codecs.
codecsCodecProfile[]Available profiles that name a codec, ordered by index.
deviceintegerPipeWire device id, the first argument of set_bluetooth_profile.
macstringMAC address from the bluez_card.* name, _ turned to :.

CodecProfile

One entry of BluetoothCodecs::codecs.

FieldTypeDescription
codecstringCodec from the profile name, else its English description, e.g. "AAC", "LDAC", "mSBC".
descriptionstringPipeWire’s description, e.g. "High Fidelity Playback (A2DP Sink, codec AAC)".
indexintegerProfile index, the second argument of set_bluetooth_profile.

Actions

Call each as mantle.audio:<action>(arguments...); ? marks an argument you may omit.

ActionArgumentsDescription
set_volumevolume: numberSets master output volume, clamped to [0.0, 1.5].
set_mutedmuted: booleanSets master output mute.
toggle_muteToggles master output mute.
set_balancebalance: numberSets default output balance, -1.0 (left) to 1.0 (right), clamped; the louder side keeps its level.
set_default_sinkid: integerMakes this sinks[].id the default output.
set_default_sourceid: integerMakes this sources[].id the default input.
set_source_volumevolume: numberSets default input volume, clamped to [0.0, 1.0].
set_source_mutedmuted: booleanSets default input mute.
toggle_source_muteToggles default input mute.
set_app_volumeid: integer, volume: numberSets an apps[].id stream’s volume, clamped to [0.0, 1.0].
set_app_mutedid: integer, muted: booleanSets an apps[].id stream’s mute.
set_bluetooth_profiledevice: integer, index: integerSwitches a bluetooth[].device to one of its codecs[].index.

Backend

PipeWire’s native API, on one thread shared with privacy.

PipeWire objectFeeds
Audio/Sink, Audio/Source nodes and the default metadata’s default.audio.sink/sourcesinks, sources, volume, muted, balance, source_volume, source_muted
Stream/Output/Audio, Stream/Input/Audio nodesapps, minus the streams its field lists
bluez_card.* devices and their profilesbluetooth

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.

Source: supervisor/src/capabilities/audio/