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 {
    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.

FieldTypeDescription
appsAppStream[]Apps playing or recording audio, excluding pid-less streams, notification sounds, meters and monitor captures.
cardsAudioCard[]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.
sinksAudioDevice[]Every output device; the active one is the default.
sourcesAudioDevice[]Every input device; the active one is the default.

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, set_app_muted and set_app_target.
media_name?stringmedia.name, which tells one app’s streams apart, e.g. a browser tab’s "YouTube Music". Follows the stream as the client renames it.
mutedbooleanStream mute; false until volume is known.
name?stringapplication.name, if the client set one.
pidintegerOwning process id, from application.process.id.
pinned_to?integerThe 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.
recordingbooleanA capture stream, such as a call’s microphone, rather than playback.
target?integerThe sinks[].id a playback stream plays into, or the sources[].id a recording reads; nil while it is linked to neither.
volume?numberStream volume in percent; nil until PipeWire reports the stream’s Props.

AudioCard

One ALSA or BlueZ card and every profile it offers.

FieldTypeDescription
active?integerindex of the active profile; nil before PipeWire reports it.
deviceintegerPipeWire device id, the first argument of set_card_profile.
mac?stringA BlueZ card’s MAC address, from its bluez_card.* name with _ turned to :; joins mantle.bluetooth. nil on an ALSA card.
namestringdevice.description, e.g. "Built-in Audio", else device.nick, else device.name.
portsCardPort[]Every port, unplugged ones included, ordered by index. A device’s port is its active one.
profilesCardProfile[]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.

FieldTypeDescription
indexintegerZero-based index for set_channel_volume.
position?stringSpeaker position, e.g. "front_left" or "front_right".
volumenumberReported level in percent. Sink writes stop at 150; only the default output is corrected above it.

AudioDevice

One sinks or sources entry.

FieldTypeDescription
activebooleanThis 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?stringdevice.bus, e.g. "pci", "usb", "bluetooth".
card?integerThe 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?stringdevice.form-factor, e.g. "headset".
icon?stringdevice.icon-name theme name, e.g. "audio-card-analog".
idintegerPipeWire node id, the first argument of every device action; not reboot-stable.
mutedbooleanfalse before its first report.
namestringnode.description, e.g. "Built-in Audio Analog Stereo", else node.nick, else node.name.
node_namestringnode.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?stringThe active card route’s port.type, e.g. "headphones", "hdmi", "mic".
volume?numberVolume 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.

FieldTypeDescription
activebooleanThe 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.
availablebooleanfalse when PipeWire reports the port unavailable, such as an unplugged jack.
descriptionstringPipeWire’s description, e.g. "Headphones".
directionPortDirection"output" for a sink’s port, "input" for a source’s.
indexintegerPort index, the second argument of set_port.
namestringPipeWire’s untranslated id, e.g. "analog-output-headphones".
profilesinteger[]profiles[].index values the port works under.
type?stringport.type, e.g. "speaker", "headphones", "hdmi", "mic".

CardProfile

One entry of AudioCard.profiles.

FieldTypeDescription
availablebooleanfalse when PipeWire reports the profile unavailable, such as HDMI with no display plugged in.
codec?stringThe Bluetooth codec the profile names, e.g. "AAC", "LDAC", "mSBC"; nil when it names none, as off and every ALSA profile.
descriptionstringPipeWire’s description, e.g. "Digital Stereo (HDMI) Output".
indexintegerProfile index, the second argument of set_card_profile.
inputsintegerSources the profile makes; 0 for an output-only profile or off.
namestringPipeWire’s untranslated id, e.g. "output:hdmi-stereo" or "off".
outputsintegerSinks the profile makes; 0 for an input-only profile or off.

PortDirection

Whether a port plays or records.

ValueDescription
"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.

ActionArgumentsDescription
set_volumeid: integer, volume: numberSets 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_mutedid: integer, muted: booleanSets a sinks[].id or sources[].id mute.
toggle_muteid: integerToggles a sinks[].id or sources[].id mute.
set_balanceid: integer, balance: numberSets a sinks[].id or sources[].id 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. WirePlumber saves the choice, so a volume change, re-plug or restart keeps it.
set_default_sourceid: integerMakes this sources[].id the default input. WirePlumber saves the choice, so a volume change, re-plug or restart keeps it.
set_channel_volumeid: integer, index: integer, volume: numberSets one channels[] level of a sinks[].id or sources[].id in percent, clamped as set_volume. Other channels stay unchanged.
set_app_volumeid: integer, volume: numberSets an apps[].id stream’s volume in percent, clamped to [0, 100].
set_app_mutedid: integer, muted: booleanSets an apps[].id stream’s mute.
set_app_targetid: integer, device?: integerMoves 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_profiledevice: integer, index: integerSwitches a cards[].device to one of its profiles[].index. WirePlumber keeps the choice.
set_portcard: integer, index: integerRoutes 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_devicesdevices?: 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_metersids?: 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.

FieldTypeDescription
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?stringnode.description, the name a mixer shows.
kindVirtualKind
namestringnode.name, unique among the declared devices.

VirtualKind

What makes a virtual device.

ValueDescription
"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 objectFeeds
Audio/Sink, Audio/Source nodes and the default metadata’s default.audio.sink/sourcesinks, sources, their active, channels, volume, muted and balance; card is the node’s device.id
default.configured.audio.sink/source in the default metadataWritten 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 nodesapps, minus the streams its field lists
Link globalsan app’s target: the sink or source its links reach
target.object in the default metadata, keyed by the stream’s idAn 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 profilescards; 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 EnumRoutecards[].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 childset_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 namesaudio_levels, pushed apart from this state
Each ALSA and BlueZ device’s active Routeport, 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" }

Source: supervisor/src/capabilities/audio/