Skip to content

PwNodeAudio volume writes only update local state on Bluetooth sinks without volumeStep #807

@faithleysath

Description

@faithleysath

Summary

Setting PwNodeAudio.volume / PwNodeAudio.volumes on my default Bluetooth sink updates the QML-side value, but it does not change the PipeWire backend volume. Muting the same sink through PwNodeAudio.muted works, and changing the same sink through wpctl set-volume works.

This first showed up through DankMaterialShell master volume control, but I reduced it to Quickshell directly. DMS is doing the expected assignment to Pipewire.defaultAudioSink.audio.volume; the assignment just does not reach PipeWire for this sink.

Environment

  • Quickshell: 0.3.0 (Arch/CachyOS package)
  • PipeWire: 1.6.5
  • WirePlumber: 0.5.14
  • Sink: Bluetooth / BlueZ output, bluez_output.6C_5A_B5_EB_5E_8D.1 (DingDong-124)

There is also a local packaging warning that the package was built against Qt 6.11.0 while the system has Qt 6.11.1. I am including that for completeness, but the behavior below looks specific to the PipeWire volume write path rather than a crash or Qt issue.

Minimal repro

With the Bluetooth sink as the default sink, wpctl get-volume @DEFAULT_AUDIO_SINK@ initially reports:

Volume: 0.40

Running this minimal Quickshell config changes the QML properties but not the actual backend volume:

import QtQuick
import Quickshell
import Quickshell.Services.Pipewire

ShellRoot {
    PwObjectTracker {
        objects: Pipewire.nodes.values.filter(node => node.audio && !node.isStream)
    }

    function logSink(prefix) {
        const sink = Pipewire.defaultAudioSink;
        console.log(prefix
            + " id=" + sink.id
            + " name=" + sink.name
            + " ready=" + sink.ready
            + " volume=" + sink.audio.volume
            + " muted=" + sink.audio.muted
            + " volumes=" + JSON.stringify(sink.audio.volumes));
    }

    Timer {
        interval: 1500
        running: true
        repeat: false
        onTriggered: {
            logSink("before");
            Pipewire.defaultAudioSink.audio.volume = 0.31;
            afterAverageTimer.start();
        }
    }

    Timer {
        id: afterAverageTimer
        interval: 1000
        repeat: false
        onTriggered: {
            logSink("after-average");
            Pipewire.defaultAudioSink.audio.volumes = [0.27, 0.27];
            afterChannelsTimer.start();
        }
    }

    Timer {
        id: afterChannelsTimer
        interval: 1000
        repeat: false
        onTriggered: {
            logSink("after-channels");
            Qt.quit();
        }
    }
}

Observed Quickshell log:

before id=32 name=bluez_output.6C_5A_B5_EB_5E_8D.1 ready=true volume=0.4000000059604645 muted=false volumes=[0.4000000059604645,0.4000000059604645]
after-average id=32 name=bluez_output.6C_5A_B5_EB_5E_8D.1 ready=true volume=0.31 muted=false volumes=[0.31,0.31]
after-channels id=32 name=bluez_output.6C_5A_B5_EB_5E_8D.1 ready=true volume=0.27 muted=false volumes=[0.27,0.27]

But after the script exits, PipeWire still has the old volume:

$ wpctl get-volume @DEFAULT_AUDIO_SINK@
Volume: 0.40

Directly changing the same sink works:

$ wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.35
$ wpctl get-volume @DEFAULT_AUDIO_SINK@
Volume: 0.35

Muting through Quickshell/DMS also works; only the volume write fails.

Route detail

For this Bluetooth device route, pw-cli enum-params <bluez-device-id> Route contains mute and channelVolumes, but no volumeStep:

mute false
channelVolumes Float 0.064000 0.064000
channelMap Id 3 4

On the same machine, internal/HDMI device routes do include volumeStep, for example:

volumeStep Float 0.000015

So this may be specific to device-backed routes where PipeWire/WirePlumber does not expose a volumeStep field, which seems common for this BlueZ sink.

Suspected cause

Looking at PwNodeBoundAudio::setVolumes() in src/services/pipewire/node.cpp, the device-backed branch appears to call device->setVolumes(...) only when this->volumeStep != -1. If volumeStep == -1, the backend write is skipped, but the function still assigns mVolumes and emits volumesChanged(). That matches the observed behavior exactly: QML state changes, but PipeWire remains unchanged.

setMuted() does not have the same volumeStep guard before calling device->setMuted(...), which also matches the fact that mute works on the same sink.

Expected behavior would be for volumeStep == -1 to still write the route volume, or at least not silently update the local QML state as though the backend accepted it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions