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:
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.
Summary
Setting
PwNodeAudio.volume/PwNodeAudio.volumeson my default Bluetooth sink updates the QML-side value, but it does not change the PipeWire backend volume. Muting the same sink throughPwNodeAudio.mutedworks, and changing the same sink throughwpctl set-volumeworks.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
0.3.0(Arch/CachyOS package)1.6.50.5.14bluez_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:Running this minimal Quickshell config changes the QML properties but not the actual backend volume:
Observed Quickshell log:
But after the script exits, PipeWire still has the old volume:
Directly changing the same sink works:
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> RoutecontainsmuteandchannelVolumes, but novolumeStep:On the same machine, internal/HDMI device routes do include
volumeStep, for example:So this may be specific to device-backed routes where PipeWire/WirePlumber does not expose a
volumeStepfield, which seems common for this BlueZ sink.Suspected cause
Looking at
PwNodeBoundAudio::setVolumes()insrc/services/pipewire/node.cpp, the device-backed branch appears to calldevice->setVolumes(...)only whenthis->volumeStep != -1. IfvolumeStep == -1, the backend write is skipped, but the function still assignsmVolumesand emitsvolumesChanged(). That matches the observed behavior exactly: QML state changes, but PipeWire remains unchanged.setMuted()does not have the samevolumeStepguard before callingdevice->setMuted(...), which also matches the fact that mute works on the same sink.Expected behavior would be for
volumeStep == -1to still write the route volume, or at least not silently update the local QML state as though the backend accepted it.