feat(quickshell): add native audio mixer

This commit is contained in:
fy59 2026-10-08 22:30:37 +02:00
parent 8551f8357b
commit c81b943fa0
9 changed files with 522 additions and 3 deletions

View file

@ -5,6 +5,10 @@ Le [moteur de thème commun](theme/README.md) fournit les couleurs effectives
Barre principale et serveur de notifications de la session SwayFX. Barre principale et serveur de notifications de la session SwayFX.
Le haut-parleur de la barre ouvre le [mixeur audio du Control Center](controlcenter/AUDIO_MIXER.md),
avec sorties, flux de lecture et microphones. Le volume général conserve son
contrôleur partagé avec la barre et ses pas de 1 point au défilement.
Le bouton Arch **Applications**, premier élément à gauche même en mode Resize, Le bouton Arch **Applications**, premier élément à gauche même en mode Resize,
ouvre le [menu d'applications](applications/README.md) sous la barre. ouvre le [menu d'applications](applications/README.md) sous la barre.
`Mod+Alt+L` ouvre ce menu sur la sortie focalisée. `Mod+Alt+L` ouvre ce menu sur la sortie focalisée.

View file

@ -0,0 +1,44 @@
# Mixeur audio du Control Center
Le clic sur l'indicateur de volume de la barre ouvre la page **Audio** du
Control Center. Le défilement de la barre conserve ses pas de 1 point et son
contrôleur `VolumeSlider`. La page est intégrée au `StackLayout` existant et
possède un bouton Retour vers Réglages rapides ; elle ne crée ni fenêtre ni
service supplémentaire.
La page utilise `Quickshell.Services.Pipewire`. `Pipewire.nodes` fournit les
objets vivants et `PwObjectTracker` lie les nœuds audio uniquement tant que la
page est active. Le volume général lit exactement le `sinkAudio` du
`VolumeSlider` déjà partagé avec la barre. Les trois listes se fondent sur
`media.class` et les propriétés de direction : `Audio/Sink` pour les sorties,
`Audio/Source` pour les microphones et `Stream/Output/Audio` pour les flux de
lecture. Les flux de capture, de vidéo et les nœuds techniques de monitoring
ou de groupe loopback sont exclus. Chaque objet de flux garde sa propre ligne ;
aucune identité temporaire n'est persistée et l'ordre des survivants suit le
catalogue PipeWire pendant les interactions.
Choisir une sortie ou une entrée écrit `preferredDefaultAudioSink` ou
`preferredDefaultAudioSource`. La coche et le libellé « utilisée » suivent
uniquement `defaultAudioSink` et `defaultAudioSource` effectivement observés.
La préférence peut être différente de la valeur effective ou prendre du temps
à s'appliquer. Un changement de sortie par défaut ne garantit pas le transfert
des flux déjà ouverts. Le routage par application et les profils Bluetooth ne
font pas partie de cette V1.
Chaque curseur affiche le volume réel, borné à 0–100 % seulement pour une
nouvelle demande utilisateur. Une valeur externe supérieure à 100 % demeure
affichée sans écriture automatique. Le bouton muet est indépendant du curseur :
modifier le volume n'active jamais un flux ou un microphone muet. La page ne
lance aucune capture de microphone et n'écrit aucun paramètre à l'ouverture ou
à la fermeture. Les commandes restent des propriétés natives QuickShell ;
`wpctl` n'est utilisé que pour la confirmation des tests.
`AudioMixerModel.js` contient le filtrage et les conversions vérifiées par
`node tests/audio_mixer/test_model.js`. Les essais réels de V1 ont utilisé deux
flux `pw-play` silencieux et non reliés (`--target 0`) : leurs volumes ont été
réglés indépendamment depuis l'interface et confirmés par PipeWire. La
sourdine d'un flux est restée active après un déplacement de son curseur ; une
modification externe et une disparition pendant le réglage ont aussi été
reflétées dans la page. Aucun flux utilisateur n'a été modifié. Le choix réel
d'une autre sortie ou entrée et la sourdine matérielle du microphone restent à
contrôler manuellement, sans déconnecter la sortie courante ni activer le micro.

View file

@ -0,0 +1,59 @@
.pragma library
// CONTRACT: classify only ready, tracked PipeWire audio nodes by media.class
// and direction. A capture stream or technical node cannot become playback UI.
function mediaClass(node) {
return node && node.ready && node.properties
? String(node.properties["media.class"] || "") : "";
}
function outputDevices(nodes) {
return nodes.filter(node => node && node.audio && !node.isStream && node.isSink
&& mediaClass(node) === "Audio/Sink");
}
function inputDevices(nodes) {
return nodes.filter(node => node && node.audio && !node.isStream && !node.isSink
&& mediaClass(node) === "Audio/Source"
&& !node.properties["node.group"]
&& node.properties["node.virtual"] !== true
&& node.properties["node.virtual"] !== "true"
&& node.properties["node.monitor"] !== true
&& node.properties["node.monitor"] !== "true");
}
function playbackStreams(nodes) {
// Keep PipeWire's insertion order. New or removed streams do not reshuffle
// surviving controls while a user is dragging one of their sliders.
return nodes.filter(node => node && node.audio && node.isStream
&& mediaClass(node) === "Stream/Output/Audio");
}
function deviceName(node) {
return node ? (node.description || node.nickname || node.name || "Périphérique audio")
: "Périphérique indisponible";
}
function streamName(node) {
if (!node) return "Application";
const props = node.properties || {};
return props["application.name"] || node.description || node.name || "Application";
}
function streamDetail(node) {
if (!node) return "";
const name = streamName(node);
return node.name && node.name !== name ? node.name : "Flux audio · " + node.id;
}
function streamIcon(node) {
return node && node.properties ? String(node.properties["application.icon-name"] || "") : "";
}
function displayPercent(volume) {
return Math.round(Number(volume) * 100);
}
function requestedVolume(percent) {
return Math.max(0, Math.min(100, Math.round(percent))) / 100;
}

View file

@ -0,0 +1,235 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Services.Pipewire
import "../theme"
import "AudioMixerModel.js" as MixerModel
Item {
id: page
required property bool activePage
required property var volumeControl
signal backRequested()
property var pendingSink: null
property var pendingSource: null
readonly property var audioNodes: Pipewire.nodes.values.filter(node => node && node.audio)
readonly property var outputDevices: MixerModel.outputDevices(audioNodes)
readonly property var inputDevices: MixerModel.inputDevices(audioNodes)
readonly property var playbackStreams: MixerModel.playbackStreams(audioNodes)
readonly property var effectiveSink: Pipewire.defaultAudioSink
readonly property var effectiveSource: Pipewire.defaultAudioSource
// CONTRACT: bind audio nodes only while the mixer is visible. The always
// active VolumeSlider continues to own tracking of the shared default sink.
PwObjectTracker { objects: page.activePage ? page.audioNodes : [] }
onEffectiveSinkChanged: if (pendingSink === effectiveSink) pendingSink = null
onEffectiveSourceChanged: if (pendingSource === effectiveSource) pendingSource = null
onOutputDevicesChanged: if (pendingSink && !outputDevices.includes(pendingSink)) pendingSink = null
onInputDevicesChanged: if (pendingSource && !inputDevices.includes(pendingSource)) pendingSource = null
onActivePageChanged: {
if (!activePage) { pendingSink = null; pendingSource = null; }
else resetScroll.start();
}
Timer {
id: resetScroll
interval: 0; repeat: false
onTriggered: if (scroll.contentItem) scroll.contentItem.contentY = 0
}
Column {
anchors.fill: parent
spacing: 9
Row {
width: parent.width; height: 30; spacing: 8
Button {
width: 40; height: 30
Accessible.name: "Retour aux réglages rapides"
onClicked: page.backRequested()
background: Rectangle {
radius: 4
color: parent.hovered ? Theme.buttonHover : Theme.buttonBackground
border.color: parent.activeFocus ? Theme.lavender : "transparent"
}
contentItem: Text {
text: ""; color: Theme.foreground
font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 14
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Text {
height: 30; verticalAlignment: Text.AlignVCenter
text: "Audio"
color: Theme.foreground; font.pixelSize: 16; font.bold: true
}
}
ScrollView {
id: scroll
width: parent.width
height: Math.max(0, parent.height - y)
clip: true
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
Column {
width: scroll.availableWidth
spacing: 8
Text { text: "SORTIE AUDIO"; color: Theme.lavender; font.pixelSize: 11; font.bold: true }
Text {
width: parent.width
text: !Pipewire.ready ? "Connexion audio en cours…"
: page.effectiveSink ? "Utilisée : " + MixerModel.deviceName(page.effectiveSink)
: "Aucune sortie active"
color: Theme.secondaryForeground; font.pixelSize: 11
elide: Text.ElideRight
}
Repeater {
model: page.outputDevices
delegate: Button {
required property var modelData
width: scroll.availableWidth; height: 31
enabled: page.activePage && modelData.ready
Accessible.name: "Choisir la sortie " + MixerModel.deviceName(modelData)
onClicked: {
if (page.effectiveSink === modelData) return;
page.pendingSink = modelData;
Pipewire.preferredDefaultAudioSink = modelData;
}
background: Rectangle {
radius: 4
color: page.effectiveSink === modelData ? Theme.buttonPressed
: parent.hovered ? Theme.buttonHover : Theme.buttonBackground
border.color: parent.activeFocus ? Theme.lavender : "transparent"
}
contentItem: Row {
spacing: 7
Text {
width: 18; height: 18; anchors.verticalCenter: parent.verticalCenter
text: "󰕾"; color: Theme.lavender
font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 15
}
Text {
width: scroll.availableWidth - 54
anchors.verticalCenter: parent.verticalCenter
text: MixerModel.deviceName(modelData)
color: Theme.foreground; font.pixelSize: 11; elide: Text.ElideRight
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: page.effectiveSink === modelData ? "✓" : ""
color: Theme.lavender; font.pixelSize: 12
}
}
}
}
Text {
visible: page.outputDevices.length === 0 && Pipewire.ready
text: "Aucune sortie disponible"
color: Theme.secondaryForeground; font.pixelSize: 11
}
Text {
visible: page.pendingSink !== null && page.pendingSink !== page.effectiveSink
text: "Changement de sortie demandé…"
color: Theme.secondaryForeground; font.pixelSize: 10
}
MixerLevelRow {
width: scroll.availableWidth
title: "Volume général"
fallbackGlyph: "󰕾"
audio: page.volumeControl.sinkAudio
}
Rectangle { width: scroll.availableWidth; height: 1; color: Theme.separator }
Text { text: "APPLICATIONS"; color: Theme.lavender; font.pixelSize: 11; font.bold: true }
Text {
visible: page.playbackStreams.length === 0
text: Pipewire.ready ? "Aucun flux de lecture" : "Recherche des flux…"
color: Theme.secondaryForeground; font.pixelSize: 11
}
Repeater {
model: page.playbackStreams
delegate: MixerLevelRow {
required property var modelData
width: scroll.availableWidth
title: MixerModel.streamName(modelData)
detail: MixerModel.streamDetail(modelData)
iconName: MixerModel.streamIcon(modelData)
fallbackGlyph: "󰀻"
audio: modelData && modelData.ready ? modelData.audio : null
}
}
Rectangle { width: scroll.availableWidth; height: 1; color: Theme.separator }
Text { text: "MICROPHONE"; color: Theme.lavender; font.pixelSize: 11; font.bold: true }
Text {
width: parent.width
text: !Pipewire.ready ? "Connexion audio en cours…"
: page.effectiveSource ? "Utilisé : " + MixerModel.deviceName(page.effectiveSource)
: "Aucun microphone actif"
color: Theme.secondaryForeground; font.pixelSize: 11
elide: Text.ElideRight
}
Repeater {
model: page.inputDevices
delegate: Button {
required property var modelData
width: scroll.availableWidth; height: 31
enabled: page.activePage && modelData.ready
Accessible.name: "Choisir le microphone " + MixerModel.deviceName(modelData)
onClicked: {
if (page.effectiveSource === modelData) return;
page.pendingSource = modelData;
Pipewire.preferredDefaultAudioSource = modelData;
}
background: Rectangle {
radius: 4
color: page.effectiveSource === modelData ? Theme.buttonPressed
: parent.hovered ? Theme.buttonHover : Theme.buttonBackground
border.color: parent.activeFocus ? Theme.lavender : "transparent"
}
contentItem: Row {
spacing: 7
Text {
width: 18; height: 18; anchors.verticalCenter: parent.verticalCenter
text: ""; color: Theme.lavender
font.family: "JetBrainsMono Nerd Font"; font.pixelSize: 15
}
Text {
width: scroll.availableWidth - 54
anchors.verticalCenter: parent.verticalCenter
text: MixerModel.deviceName(modelData)
color: Theme.foreground; font.pixelSize: 11; elide: Text.ElideRight
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: page.effectiveSource === modelData ? "✓" : ""
color: Theme.lavender; font.pixelSize: 12
}
}
}
}
Text {
visible: page.inputDevices.length === 0 && Pipewire.ready
text: "Aucun microphone disponible"
color: Theme.secondaryForeground; font.pixelSize: 11
}
Text {
visible: page.pendingSource !== null && page.pendingSource !== page.effectiveSource
text: "Changement de microphone demandé…"
color: Theme.secondaryForeground; font.pixelSize: 10
}
MixerLevelRow {
width: scroll.availableWidth
title: "Niveau du microphone"
fallbackGlyph: ""
microphone: true
audio: page.effectiveSource && page.effectiveSource.ready
? page.effectiveSource.audio : null
}
Item { width: 1; height: 5 }
}
}
}
}

View file

@ -17,7 +17,7 @@ PopupWindow {
readonly property var pages: ({ main: 0, wifi: 1, bluetooth: 2, session: 3, readonly property var pages: ({ main: 0, wifi: 1, bluetooth: 2, session: 3,
confirmation: 4, battery: 5, appearance: 6, wallpaper: 7, theme: 8, confirmation: 4, battery: 5, appearance: 6, wallpaper: 7, theme: 8,
nightLight: 9, sunMode: 10, locationTime: 11, sessionManager: 12, nightLight: 9, sunMode: 10, locationTime: 11, sessionManager: 12,
checkpointFailure: 13, avatar: 14 }) checkpointFailure: 13, avatar: 14, audio: 15 })
property int currentPage: pages.main property int currentPage: pages.main
function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; } function openPage(name) { if (pages[name] !== undefined) currentPage = pages[name]; }
signal thresholdApplied() signal thresholdApplied()
@ -125,7 +125,8 @@ PopupWindow {
: currentPage === 5 ? batteryPage.implicitHeight + 32 : currentPage === 5 ? batteryPage.implicitHeight + 32
: currentPage === pages.wallpaper ? 550 : currentPage === pages.wallpaper ? 550
: currentPage === pages.appearance ? 610 : currentPage === pages.appearance ? 610
: currentPage === pages.locationTime || currentPage === pages.sessionManager ? 650 : 440 : currentPage === pages.locationTime || currentPage === pages.sessionManager ? 650
: currentPage === pages.audio ? 620 : 440
visible: false visible: false
// CONTRACT: PopupWindow n'applique un changement de grabFocus qu'après // CONTRACT: PopupWindow n'applique un changement de grabFocus qu'après
// fermeture/réouverture ; le prendre dès MAIN garde le clavier disponible // fermeture/réouverture ; le prendre dès MAIN garde le clavier disponible
@ -330,6 +331,11 @@ PopupWindow {
activePage: popup.visible && popup.currentPage === popup.pages.avatar activePage: popup.visible && popup.currentPage === popup.pages.avatar
onBackRequested: popup.openPage("appearance") onBackRequested: popup.openPage("appearance")
} }
AudioMixerPage {
activePage: popup.visible && popup.currentPage === popup.pages.audio
volumeControl: volumeSlider
onBackRequested: popup.openPage("main")
}
} }
} }
} }

View file

@ -0,0 +1,118 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import "../theme"
import "AudioMixerModel.js" as MixerModel
Item {
id: row
required property string title
property string detail: ""
property string iconName: ""
property string fallbackGlyph: ""
property bool microphone: false
property var audio: null
readonly property int percent: audio ? MixerModel.displayPercent(audio.volume) : 0
implicitHeight: detail ? 74 : 61
Image {
id: icon
anchors.left: parent.left
anchors.top: parent.top
width: 22; height: 22
source: row.iconName ? Quickshell.iconPath(row.iconName) : ""
sourceSize.width: 22; sourceSize.height: 22
visible: status === Image.Ready
fillMode: Image.PreserveAspectFit
}
Text {
anchors.centerIn: icon
visible: !icon.visible
text: row.fallbackGlyph
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 17
color: Theme.lavender
}
Text {
id: titleText
anchors.left: icon.right; anchors.leftMargin: 8
anchors.right: valueText.left; anchors.rightMargin: 5
anchors.top: parent.top
text: row.title
color: Theme.foreground
font.pixelSize: 12
elide: Text.ElideRight
}
Text {
visible: row.detail !== ""
anchors.left: titleText.left; anchors.right: titleText.right
anchors.top: titleText.bottom; anchors.topMargin: 1
text: row.detail
color: Theme.secondaryForeground
font.pixelSize: 10
elide: Text.ElideRight
}
Text {
id: valueText
anchors.right: muteButton.left; anchors.rightMargin: 5
anchors.top: parent.top
width: 43
horizontalAlignment: Text.AlignRight
text: row.audio ? row.percent + " %" : "—"
color: Theme.secondaryForeground
font.pixelSize: 11
}
Button {
id: muteButton
anchors.right: parent.right; anchors.top: parent.top
width: 27; height: 25
enabled: row.audio !== null
Accessible.name: row.audio && row.audio.muted
? "Réactiver " + row.title : "Couper " + row.title
onClicked: if (row.audio) row.audio.muted = !row.audio.muted
background: Rectangle {
radius: 4
color: row.audio && row.audio.muted ? Theme.buttonPressed
: muteButton.hovered ? Theme.buttonHover : Theme.buttonBackground
border.color: muteButton.activeFocus ? Theme.lavender : "transparent"
}
contentItem: Text {
text: row.microphone ? (row.audio && row.audio.muted ? "" : "")
: row.audio && row.audio.muted ? "󰝟" : "󰕾"
font.family: "JetBrainsMono Nerd Font"
font.pixelSize: 15
color: row.audio && row.audio.muted ? Theme.danger : Theme.foreground
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Slider {
id: level
anchors.left: parent.left; anchors.right: parent.right
anchors.bottom: parent.bottom
height: 29
from: 0; to: 100; stepSize: 1
enabled: row.audio !== null
// WHY: an external value above 100 % remains visible in the label;
// clamping the slider's display must never write back on its own.
value: row.audio ? Math.max(0, Math.min(100, row.percent)) : 0
Accessible.name: "Volume de " + row.title
onMoved: if (row.audio) row.audio.volume = MixerModel.requestedVolume(value)
background: Rectangle {
x: level.leftPadding
y: level.topPadding + level.availableHeight / 2 - height / 2
width: level.availableWidth; height: 8; radius: 4
color: Theme.buttonBackground
Rectangle {
width: level.visualPosition * parent.width
height: parent.height; radius: 4; color: Theme.accent
}
}
handle: Rectangle {
x: level.leftPadding + level.visualPosition * (level.availableWidth - width)
y: level.topPadding + level.availableHeight / 2 - height / 2
width: 16; height: 16; radius: 4
color: level.activeFocus ? Theme.lavender : Theme.foreground
}
}
}

View file

@ -39,6 +39,8 @@ Row {
label: "Volume" label: "Volume"
percent: area.audioBrightnessController.volumePercent percent: area.audioBrightnessController.volumePercent
available: area.audioBrightnessController.volumeAvailable available: area.audioBrightnessController.volumeAvailable
clickable: true
onActivated: area.openPageRequested(15)
onAdjusted: steps => area.audioBrightnessController.adjustVolume(steps) onAdjusted: steps => area.audioBrightnessController.adjustVolume(steps)
} }
ScrollIcon { ScrollIcon {

View file

@ -11,7 +11,9 @@ Item {
property int glyphPixelSize: 22 property int glyphPixelSize: 22
property color glyphColor: Theme.foreground property color glyphColor: Theme.foreground
property bool available: false property bool available: false
property bool clickable: false
signal adjusted(int steps) signal adjusted(int steps)
signal activated()
// CONTRACT: la hitbox et la valeur partagent la même largeur stable que // CONTRACT: la hitbox et la valeur partagent la même largeur stable que
// la batterie ; le texte débute à 4 px de l'icône, sans vide variable. // la batterie ; le texte débute à 4 px de l'icône, sans vide variable.
width: content.width width: content.width
@ -47,8 +49,9 @@ Item {
MouseArea { MouseArea {
id: pointer id: pointer
anchors.fill: parent anchors.fill: parent
acceptedButtons: Qt.NoButton acceptedButtons: iconButton.clickable ? Qt.LeftButton : Qt.NoButton
hoverEnabled: true hoverEnabled: true
onClicked: iconButton.activated()
} }
StatusTooltip { StatusTooltip {
target: iconButton target: iconButton

View file

@ -0,0 +1,48 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const vm = require("node:vm");
const path = require("node:path");
const source = fs.readFileSync(path.join(__dirname,
"../../quickshell/.config/quickshell/labfy-sway/controlcenter/AudioMixerModel.js"), "utf8")
.replace(/^\.pragma library\s*\n/, "");
const model = vm.createContext({});
vm.runInContext(source, model);
function node(id, media, stream, sink, extra = {}) {
return { id, ready: true, audio: { volume: 0.4, muted: false },
isStream: stream, isSink: sink, name: "node-" + id,
properties: { "media.class": media, ...extra } };
}
const output = node(1, "Audio/Sink", false, true);
const input = node(2, "Audio/Source", false, false);
const playbackA = node(3, "Stream/Output/Audio", true, true,
{ "application.name": "Test", "application.icon-name": "test-icon" });
const playbackB = node(4, "Stream/Output/Audio", true, true,
{ "application.name": "Test" });
const capture = node(5, "Stream/Input/Audio", true, false);
const internal = node(6, "Stream/Input/Audio/Internal", true, true);
const monitor = node(7, "Audio/Source", false, false, { "node.monitor": true });
const grouped = node(8, "Audio/Source", false, false, { "node.group": "loopback" });
const video = node(9, "Video/Source", false, false);
const unready = { ...node(10, "Stream/Output/Audio", true, true), ready: false };
const all = [output, input, playbackA, playbackB, capture, internal, monitor,
grouped, video, unready];
assert.deepEqual(Array.from(model.outputDevices(all), n => n.id), [1]);
assert.deepEqual(Array.from(model.inputDevices(all), n => n.id), [2]);
assert.deepEqual(Array.from(model.playbackStreams(all), n => n.id), [3, 4]);
assert.deepEqual(Array.from(model.playbackStreams([])), []);
assert.deepEqual(Array.from(model.playbackStreams([playbackB])), [playbackB]);
assert.deepEqual(Array.from(model.playbackStreams(all.filter(n => n !== playbackA)), n => n.id), [4]);
assert.equal(model.streamName(playbackA), "Test");
assert.equal(model.streamDetail(playbackA), "node-3");
assert.equal(model.streamIcon(playbackA), "test-icon");
assert.equal(model.streamIcon(playbackB), "");
assert.equal(model.streamName(node(11, "Stream/Output/Audio", true, true)), "node-11");
assert.equal(model.deviceName(null), "Périphérique indisponible");
assert.equal(model.displayPercent(1.25), 125);
assert.equal(model.requestedVolume(-4), 0);
assert.equal(model.requestedVolume(53.6), 0.54);
assert.equal(model.requestedVolume(120), 1);
console.log("AudioMixerModel: PASS");