Give Sound the whole story, and keep the buttons inside the card

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 11:39:16 -04:00
parent 9bc68ba358
commit b58371bb35
38 changed files with 3884 additions and 213 deletions
@@ -75,6 +75,21 @@ ShellRoot {
return AudioStreams.group(fixtureNodes, audioOutStreamFlag);
}
// A throwaway two-stream group for the clamp cases. The shared fixture
// above is mutated by the volume and mute tests, and a clamp assertion that
// depended on which of those ran first would be worthless.
function clampFixture(): var {
return {
key: "clamp",
label: "Clamp",
icon: "audio-x-generic-symbolic",
nodes: [
{ audio: { volume: 0.1, muted: true } },
{ audio: { volume: 0.1, muted: true } }
]
};
}
IpcHandler {
target: "application-volume-test"
@@ -115,6 +130,31 @@ ShellRoot {
});
}
// Over-amplification is a preference, so the ceiling is an argument
// rather than a constant -- this file stays Settings-free on purpose.
function clampVolume(): string {
const overAmp = clampFixture();
const overAmpChanged = AudioStreams.setVolume(overAmp, 1.4, 1.5);
const ceiling = clampFixture();
AudioStreams.setVolume(ceiling, 2.5, 1.5);
const defaultMax = clampFixture();
AudioStreams.setVolume(defaultMax, 1.4);
const floor = clampFixture();
AudioStreams.setVolume(floor, -0.5, 1.5);
const nonNumeric = clampFixture();
const nonNumericChanged = AudioStreams.setVolume(nonNumeric, "loud", 1.5);
return JSON.stringify({
overAmpChanged,
overAmp: overAmp.nodes.map(node => node.audio.volume),
overAmpMuted: overAmp.nodes.map(node => node.audio.muted),
ceiling: ceiling.nodes.map(node => node.audio.volume),
defaultMax: defaultMax.nodes.map(node => node.audio.volume),
floor: floor.nodes.map(node => node.audio.volume),
nonNumericChanged,
nonNumeric: nonNumeric.nodes.map(node => node.audio.volume)
});
}
function serviceSummary(): string {
const applications = AudioDevices.applications;
return JSON.stringify({
@@ -1487,6 +1487,24 @@ Singleton {
detail: "Maximum notification banners shown at once"
},
// ── Sound ───────────────────────────────────────────────────────────
// The two audio preferences that are Panama's own. Everything else on
// the Sound page is live PipeWire or a GNOME desktop key, and belongs
// to the system rather than to this file.
//
// Both are read by scripts/panama-osd as well as by the shell, so the
// volume keys behave the same whether the panel is open or not.
{
key: "overAmplification", type: "bool", def: false, group: "sound",
label: "Over-amplification",
detail: "Lets the volume slider go to 150% — louder, at the cost of distortion on some hardware"
},
{
key: "volumeChangeBlip", type: "bool", def: true, group: "sound",
label: "Volume-change blip",
detail: "A short click each time the volume keys move the output level"
},
// ── Capture ─────────────────────────────────────────────────────────
// Directories and encoder arguments are enums rather than free text:
// both are handed to a recorder process, and an arbitrary string there
@@ -83,6 +83,13 @@ Singleton {
readonly property int notificationHistoryLimit: DesktopPreferences.get("notificationHistoryLimit")
readonly property int maxVisibleToasts: DesktopPreferences.get("maxVisibleToasts")
// ── Sound ───────────────────────────────────────────────────────────────
// Over-amplification is the clamp ceiling for output volume: off means 1.0,
// on means 1.5. Every slider and the volume keys read the same switch, so
// the ceiling cannot differ depending on where you changed the volume from.
readonly property bool overAmplification: DesktopPreferences.get("overAmplification")
readonly property bool volumeChangeBlip: DesktopPreferences.get("volumeChangeBlip")
// ── Focus ──────────────────────────────────────────────────────────────
// One deliberate default rather than a preset picker: quick settings and
// the keyboard shortcut should start a useful session in one action.
@@ -25,6 +25,10 @@ Item {
readonly property real volume: root.node && root.node.audio ? root.node.audio.volume : 0
readonly property bool muted: root.node && root.node.audio ? root.node.audio.muted : false
// Over-amplification extends the output to 150%. The input is never
// extended: a microphone above 100% is gain on noise, not loudness.
readonly property real maximum: root.output && Settings.overAmplification ? 1.5 : 1
PwObjectTracker {
objects: root.node ? [root.node] : []
}
@@ -57,21 +61,46 @@ Item {
}
ValueSlider {
id: slider
anchors.left: muteButton.right
anchors.leftMargin: 6
anchors.right: chevron.left
anchors.rightMargin: 4
anchors.verticalCenter: parent.verticalCenter
value: root.muted ? 0 : root.volume
value: root.muted ? 0 : root.volume / root.maximum
onMoved: v => {
if (!root.node || !root.node.audio)
return;
// Nudging the slider is also how you unmute, same as GNOME.
root.node.audio.muted = false;
root.node.audio.volume = v;
root.node.audio.volume = v * root.maximum;
}
}
// Everything above 100%, marked over the track so the loud end of the
// slider is visibly the loud end rather than more of the same.
Rectangle {
anchors.right: slider.right
anchors.verticalCenter: slider.verticalCenter
width: root.maximum > 1 ? slider.width * (1 - 1 / root.maximum) : 0
height: 10
radius: 2
visible: root.maximum > 1
color: Theme.alpha(Theme.warn, 0.30)
border.width: 0
}
Rectangle {
anchors.verticalCenter: slider.verticalCenter
x: slider.x + slider.width / root.maximum - 1
width: 2
height: 16
radius: 1
visible: root.maximum > 1
color: Theme.alpha(Theme.warn, 0.7)
border.width: 0
}
IconButton {
id: chevron
anchors.right: parent.right
@@ -1,3 +1,11 @@
// One application's own level, and where it plays.
//
// The destination is part of the row because it is part of the same question:
// an application is too loud, or it is coming out of the wrong speakers, and
// both are answered here rather than in a mixer somebody else ships. Picking a
// device moves every stream the application owns; "System default" hands it
// back to following whatever the Output card says.
import Quickshell.Services.Pipewire
import QtQuick
@@ -11,11 +19,25 @@ Rectangle {
required property var application
// Open only while a destination is being chosen. The row is otherwise one
// line tall, because the list of sinks is longer than the mixer usually is.
property bool picking: false
readonly property real volume: AudioDevices.applicationVolume(root.application)
readonly property bool muted: AudioDevices.applicationMuted(root.application)
// "" means the application follows the default sink rather than naming one.
readonly property string sinkName: String(SoundRouting.currentSinkFor(root.application) ?? "")
readonly property string sinkLabel: {
if (root.sinkName === "")
return "System default";
const node = AudioDevices.outputs.find(candidate =>
String(candidate?.name ?? "") === root.sinkName);
return node ? AudioDevices.label(node) : root.sinkName;
}
width: parent ? parent.width : 620
implicitHeight: 78
implicitHeight: 78 + (root.picking ? sinkMenu.implicitHeight + 6 : 0)
radius: Theme.cardRadius
color: Theme.alpha(Theme.fg, 0.025)
border.width: 1
@@ -37,10 +59,20 @@ Rectangle {
tint: Theme.fg
}
SettingsButton {
id: sinkButton
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: applicationIcon.verticalCenter
text: root.sinkLabel + (root.picking ? " ▴" : " ▾")
enabled: !SoundRouting.busy
onClicked: root.picking = !root.picking
}
Column {
anchors.left: applicationIcon.right
anchors.leftMargin: 10
anchors.right: parent.right
anchors.right: sinkButton.left
anchors.rightMargin: 12
anchors.verticalCenter: applicationIcon.verticalCenter
spacing: 1
@@ -69,8 +101,8 @@ Rectangle {
id: muteButton
anchors.left: parent.left
anchors.leftMargin: 8
anchors.bottom: parent.bottom
anchors.bottomMargin: 6
anchors.top: parent.top
anchors.topMargin: 42
size: 30
iconSize: 17
icon: root.muted || root.volume <= 0.001
@@ -103,4 +135,85 @@ Rectangle {
font.pixelSize: Theme.fontSizeSmall
horizontalAlignment: Text.AlignRight
}
// The destinations: following the default, or any present output by name.
Column {
id: sinkMenu
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: 10
anchors.rightMargin: 10
anchors.top: parent.top
anchors.topMargin: 78
visible: root.picking
spacing: 0
Repeater {
model: [{
name: "",
label: "System default"
}].concat(AudioDevices.outputs.map(node => ({
name: String(node?.name ?? ""),
label: AudioDevices.label(node)
})))
Rectangle {
id: option
required property var modelData
readonly property bool chosen: String(option.modelData.name) === root.sinkName
width: sinkMenu.width
implicitHeight: 30
radius: 8
color: optionHover.hovered
? Theme.alpha(Theme.fg, 0.07)
: "transparent"
border.width: 0
Text {
anchors.left: parent.left
anchors.leftMargin: 10
anchors.right: mark.left
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: String(option.modelData.label)
color: option.chosen ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
Text {
id: mark
anchors.right: parent.right
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
visible: option.chosen
text: "✓"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
HoverHandler {
id: optionHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: {
if (option.modelData.name === "")
SoundRouting.routeToDefault(root.application);
else
SoundRouting.moveApplication(root.application, String(option.modelData.name));
root.picking = false;
}
}
}
}
}
}
@@ -13,7 +13,6 @@ SettingRow {
detail: "Adjust the left and right channels"
controlWidth: 270
visible: root.available
divider: false
PwObjectTracker {
objects: root.node ? [root.node] : []
@@ -0,0 +1,39 @@
// A pill beside a device name: how the device is attached, or why it is not
// here at all.
//
// Nine outputs named after their chipsets look alike in a list. Which of them
// arrives over the network, over Bluetooth, or is simply away in its case is
// the fact that tells them apart, so it is said next to the name rather than
// left to be inferred from a subtitle.
import QtQuick
import qs.config
Rectangle {
id: root
property string text: ""
// The token the badge is drawn from -- cyan for network devices, the accent
// for Bluetooth, muted for a device that is not present.
property color tone: Theme.accent
implicitWidth: label.implicitWidth + 14
implicitHeight: 17
radius: Theme.pillRadius
color: Theme.alpha(root.tone, 0.10)
border.width: 1
border.color: Theme.alpha(root.tone, 0.28)
Text {
id: label
anchors.centerIn: parent
text: root.text
color: root.tone
font.family: Theme.fontFamily
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 2)
font.weight: Font.DemiBold
font.capitalization: Font.AllUppercase
font.letterSpacing: 0.5
}
}
@@ -0,0 +1,86 @@
// Which applications are listening right now, and a way to shut each of them
// up without hunting through their own preferences.
//
// The row is absent rather than empty when nothing is listening: "no
// applications are using the microphone" is a sentence nobody needs to read on
// a page they opened to change an output device.
import QtQuick
import Quickshell.Services.Pipewire
import qs.config
import qs.modules.quicksettings
import qs.services
SettingRow {
id: root
readonly property var users: AudioDevices.captureApplications ?? []
label: "Using the microphone"
detail: "Applications listening right now"
// A Column skips invisible children, so an empty row costs no space.
visible: root.users.length > 0
controlWidth: Math.max(120, chips.implicitWidth + 8)
// Mute state is audio state: untracked nodes report false and swallow the
// write that would have muted them.
PwObjectTracker {
objects: root.users.reduce((all, application) =>
all.concat(application?.nodes ?? []), [])
}
Row {
id: chips
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
Repeater {
model: root.users
Rectangle {
id: chip
required property var modelData
readonly property bool muted: AudioDevices.applicationMuted(chip.modelData)
implicitWidth: name.implicitWidth + muteButton.width + 20
implicitHeight: 28
radius: Theme.pillRadius
color: Theme.alpha(Theme.fg, 0.06)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.08)
Text {
id: name
anchors.left: parent.left
anchors.leftMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: String(chip.modelData?.label ?? "")
color: chip.muted ? Theme.fgMuted : Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
IconButton {
id: muteButton
anchors.right: parent.right
anchors.rightMargin: 2
anchors.verticalCenter: parent.verticalCenter
size: 24
iconSize: 14
icon: chip.muted
? "microphone-sensitivity-muted-symbolic"
: "audio-input-microphone-symbolic"
iconFallback: "audio-input-microphone-symbolic"
tint: chip.muted ? Theme.danger : Theme.fgDim
onClicked: AudioDevices.setApplicationMuted(chip.modelData, !chip.muted)
}
}
}
}
}
@@ -0,0 +1,91 @@
// One chip per channel the selected output actually has.
//
// "Test" used to play a single front-centre sample, which answers "is this the
// right device" and nothing else. A surround receiver wired to the wrong socket
// passes that test. Each chip plays its own channel's sample, so the answer to
// "is the rear left speaker actually rear left" is one click rather than a
// guess -- and the chip lights while its sample is playing, so a silent speaker
// is visibly a silent speaker rather than a click that did nothing.
import QtQuick
import qs.config
import qs.services
import qs.widgets
Flow {
id: root
property var node: null
// [{ name, label }], ordered, from the node's own channel map.
readonly property var channels: root.node ? SoundTest.channelsFor(root.node) : []
width: parent ? parent.width : 620
spacing: 10
// Chips share the width evenly while they fit and wrap when they do not, so
// stereo gets two wide chips and 7.1 gets rows of whatever fits.
readonly property int columns: Math.max(1,
Math.min(root.channels.length, Math.floor(root.width / 150)))
readonly property real chipWidth: root.columns > 0
? (root.width - root.spacing * (root.columns - 1)) / root.columns
: root.width
Repeater {
model: root.channels
Rectangle {
id: chip
required property var modelData
readonly property string channelName: String(chip.modelData.name ?? "")
readonly property bool playing: SoundTest.playingChannel === chip.channelName
width: root.chipWidth
implicitHeight: 44
radius: Theme.cardRadius
color: chip.playing
? Theme.alpha(Theme.accent, 0.20)
: Theme.alpha(Theme.fg, chipHover.hovered ? 0.08 : 0.04)
border.width: 1
border.color: chip.playing
? Theme.alpha(Theme.accent, 0.60)
: Theme.alpha(Theme.fg, 0.09)
Accessible.role: Accessible.Button
Accessible.name: "Play " + String(chip.modelData.label ?? "")
Row {
anchors.centerIn: parent
spacing: 8
ThemedIcon {
anchors.verticalCenter: parent.verticalCenter
size: 16
icon: "audio-volume-high-symbolic"
iconFallback: "audio-speakers-symbolic"
tint: chip.playing ? Theme.fg : Theme.fgDim
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: String(chip.modelData.label ?? "")
color: chip.playing ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
}
HoverHandler {
id: chipHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: SoundTest.playChannel(root.node, chip.channelName)
}
}
}
}
@@ -1,3 +1,12 @@
// The devices of one direction, as a list you choose from.
//
// The list tells the whole story, which means it also has to account for the
// device that is not here: a session whose default is a pair of AirPods in
// their case used to show no trace of them, so the output that audio will
// return to the moment they connect looked like something Panama had
// forgotten. SoundDefaults knows the configured name, and a configured name
// with no node behind it renders last, dimmed and inert -- a ghost row.
import QtQuick
import Quickshell.Services.Pipewire
import qs.config
@@ -10,8 +19,14 @@ Column {
readonly property var nodes: AudioDevices.nodes(root.output)
readonly property var current: AudioDevices.current(root.output)
// { name, label } for the configured default that is not present, or null
// when everything the session chose is here. The service owns both the
// comparison and the naming: an absent device has no description to borrow,
// and its stored name is all there is to read it from.
readonly property var absent: SoundDefaults.absent(root.output)
width: parent ? parent.width : 620
spacing: 8
spacing: 0
Repeater {
model: root.nodes
@@ -26,9 +41,17 @@ Column {
}
}
SoundDeviceRow {
width: root.width
visible: !!root.absent
output: root.output
ghost: true
ghostLabel: root.absent ? String(root.absent.label) : ""
}
Text {
width: parent.width
visible: root.nodes.length === 0
visible: root.nodes.length === 0 && !root.absent
text: Pipewire.ready ? "No audio devices found" : "Discovering audio devices…"
color: Theme.fgDim
font.family: Theme.fontFamily
@@ -1,3 +1,16 @@
// One audio device: what it is, how it is attached, and whether it is the one
// in use.
//
// Trailing controls are measured, never guessed. The old layout subtracted the
// Use button's width from the name column and forgot both the Test button
// beside it and the spacing between them, so every output row overflowed its
// card by exactly that much. The name column now ends where the trailing Row
// begins, and that Row is as wide as whatever it happens to contain.
//
// Selecting is the row itself rather than a button, which is what freed the
// width in the first place: the radio on the right says which device is in use,
// and clicking anywhere that is not a control makes it this one.
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
@@ -6,18 +19,35 @@ import qs.widgets
import qs.services
import qs.modules.quicksettings
Rectangle {
Item {
id: root
required property var node
property var node: null
property bool output: true
property bool selected: false
property bool divider: true
implicitHeight: root.output || !root.selected ? 88 : 105
radius: Theme.cardRadius
color: root.selected ? Theme.alpha(Theme.accent, 0.09) : Theme.alpha(Theme.fg, 0.025)
border.width: 1
border.color: root.selected ? Theme.alpha(Theme.accent, 0.34) : Theme.alpha(Theme.fg, 0.07)
// A default that is configured but not present -- AirPods in their case.
// It renders as itself, dimmed and inert, rather than as an absence that
// leaves the list looking like it forgot.
property bool ghost: false
property string ghostLabel: ""
// The channel strip is per row and stays open until it is dismissed, so
// walking a surround set is one click per speaker rather than two.
property bool testOpen: false
readonly property real volume: root.node?.audio?.volume ?? 0
readonly property bool muted: root.node?.audio?.muted ?? false
readonly property var properties: root.node?.properties ?? ({})
readonly property string deviceApi: String(root.properties["device.api"] ?? "")
readonly property bool airplay: root.deviceApi === "raop"
readonly property bool bluetooth: root.deviceApi === "bluez5"
width: parent ? parent.width : 620
implicitHeight: content.implicitHeight
opacity: root.ghost ? 0.55 : 1
PwObjectTracker {
objects: root.node ? [root.node] : []
@@ -29,154 +59,303 @@ Rectangle {
enabled: !root.output && root.selected
}
readonly property real volume: root.node?.audio?.volume ?? 0
readonly property bool muted: root.node?.audio?.muted ?? false
function iconName(): string {
if (!root.output)
return root.muted ? "microphone-sensitivity-muted-symbolic" : "audio-input-microphone-symbolic";
if (root.airplay)
return "network-wireless-symbolic";
if (root.bluetooth)
return "bluetooth-active-symbolic";
if (root.muted || root.volume <= 0.001)
return "audio-volume-muted-symbolic";
if (root.volume < 0.34)
return "audio-volume-low-symbolic";
if (root.volume < 0.67)
return "audio-volume-medium-symbolic";
return "audio-volume-high-symbolic";
return "audio-speakers-symbolic";
}
Row {
id: heading
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: 12
anchors.rightMargin: 10
anchors.topMargin: 8
height: 28
spacing: 10
ThemedIcon {
anchors.verticalCenter: parent.verticalCenter
size: 20
icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic"
iconFallback: "audio-card-symbolic"
tint: root.selected ? Theme.accent : Theme.fg
}
Column {
width: Math.max(0, parent.width - 20 - useButton.width - parent.spacing * 2)
anchors.verticalCenter: parent.verticalCenter
spacing: 1
Text {
width: parent.width
text: AudioDevices.label(root.node)
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: root.selected ? Font.DemiBold : Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
visible: root.node.nickname && root.node.nickname !== AudioDevices.label(root.node)
text: root.node.nickname ?? ""
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
Row {
anchors.verticalCenter: parent.verticalCenter
spacing: 7
// Outputs only. Testing an input would mean recording and playing
// it back, which is a different thing than this button implies.
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: root.output
text: "Test"
onClicked: SoundTest.play(root.node)
}
SettingsButton {
id: useButton
anchors.verticalCenter: parent.verticalCenter
width: root.selected ? 78 : 64
text: root.selected ? "Default" : "Use"
enabled: !root.selected
onClicked: AudioDevices.select(root.output, root.node)
}
}
// 48000 -> "48 kHz", 44100 -> "44.1 kHz". Absent on plenty of nodes, which
// is why every part of the subtitle is dropped rather than defaulted.
function rateLabel(): string {
const rate = Number(root.properties["audio.rate"] ?? 0);
if (!Number.isFinite(rate) || rate <= 0)
return "";
const khz = rate / 1000;
return (khz % 1 === 0 ? String(khz) : khz.toFixed(1)) + " kHz";
}
IconButton {
id: muteButton
anchors.left: parent.left
anchors.leftMargin: 8
anchors.top: heading.bottom
anchors.topMargin: 7
size: 30
iconSize: 17
icon: root.iconName()
iconFallback: root.output ? "audio-volume-high-symbolic" : "audio-input-microphone-symbolic"
onClicked: {
if (root.node?.audio)
root.node.audio.muted = !root.node.audio.muted;
}
// "S24_32LE" -> "24-bit", "F32LE" -> "32-bit float".
function formatLabel(): string {
const format = String(root.properties["audio.format"] ?? "");
const bits = format.match(/(\d+)/);
if (!bits)
return "";
return bits[1] + "-bit" + (format.startsWith("F") ? " float" : "");
}
ValueSlider {
id: volumeSlider
anchors.left: muteButton.right
anchors.leftMargin: 7
anchors.right: volumeText.left
anchors.rightMargin: 10
anchors.verticalCenter: muteButton.verticalCenter
value: root.muted ? 0 : root.volume
onMoved: value => {
if (!root.node?.audio)
return;
root.node.audio.muted = false;
root.node.audio.volume = value;
}
function channelLabel(): string {
const channels = root.node?.audio?.channels?.length ?? 0;
if (channels === 1)
return "Mono";
if (channels === 2)
return "Stereo";
return channels > 2 ? channels + " channels" : "";
}
Text {
id: volumeText
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: muteButton.verticalCenter
width: 38
text: Math.round(root.volume * 100) + "%"
color: Theme.fgDim
font.family: Theme.fontMono
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
horizontalAlignment: Text.AlignRight
readonly property string subtitle: {
if (root.ghost)
return root.output
? "Your preferred output — audio will move here when they connect"
: "Your preferred input — Panama will listen here when it connects";
const parts = [root.channelLabel(), root.rateLabel(), root.formatLabel()]
.filter(part => part !== "");
if (parts.length > 0)
return parts.join(" · ");
const nickname = String(root.node?.nickname ?? "");
return nickname !== "" && nickname !== AudioDevices.label(root.node) ? nickname : "";
}
Rectangle {
anchors.left: volumeSlider.left
anchors.right: volumeText.right
anchors.top: muteButton.bottom
anchors.topMargin: 5
height: 5
radius: height / 2
visible: !root.output && root.selected
color: Theme.alpha(Theme.fg, 0.10)
Column {
id: content
width: parent.width
spacing: 0
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width * Math.max(0, Math.min(1, inputPeak.peak))
radius: parent.radius
color: inputPeak.peak > 0.88 ? Theme.danger : Theme.accentSecondary
id: body
width: parent.width
implicitHeight: 58
radius: Theme.cardRadius
color: rowHover.hovered && !root.ghost
? Theme.alpha(Theme.fg, 0.05)
: "transparent"
border.width: 0
Rectangle {
id: iconTile
anchors.left: parent.left
anchors.leftMargin: 4
anchors.verticalCenter: parent.verticalCenter
width: 34
height: 34
radius: 10
color: root.selected
? Theme.alpha(Theme.accent, 0.16)
: Theme.alpha(Theme.fg, 0.07)
border.width: 0
ThemedIcon {
anchors.centerIn: parent
size: 18
icon: root.iconName()
iconFallback: root.output ? "audio-card-symbolic" : "audio-input-microphone-symbolic"
tint: root.selected ? Theme.accent : Theme.fg
}
}
Row {
id: trailing
anchors.right: parent.right
anchors.rightMargin: 6
anchors.verticalCenter: parent.verticalCenter
spacing: 10
// The live level of the source being listened to. Native and
// event-driven: it repaints when a peak arrives and never on a
// timer, and it exists only for the selected input.
Rectangle {
id: meter
anchors.verticalCenter: parent.verticalCenter
visible: !root.output && root.selected && !root.ghost
width: Math.min(190, Math.max(90, root.width * 0.26))
height: 8
radius: 4
clip: true
color: Theme.alpha(Theme.fg, 0.09)
border.width: 0
Item {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width * Math.max(0, Math.min(1, inputPeak.peak))
clip: true
// Full-width gradient behind a clipped window, so a
// given level is always the same colour rather than a
// squashed copy of the whole ramp.
Rectangle {
width: meter.width
height: parent.height
radius: meter.radius
border.width: 0
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: Theme.ok }
GradientStop { position: 0.82; color: Theme.warn }
GradientStop { position: 1.0; color: Theme.danger }
}
}
}
}
// Mute belongs to the device in use, next to the level it
// silences. Scrolling it nudges that device's own volume, the
// same gesture the speaker icon carries everywhere else.
IconButton {
id: muteButton
anchors.verticalCenter: parent.verticalCenter
visible: root.selected && !root.ghost
size: 30
iconSize: 17
icon: root.muted
? (root.output ? "audio-volume-muted-symbolic" : "microphone-sensitivity-muted-symbolic")
: (root.output ? "audio-volume-high-symbolic" : "audio-input-microphone-symbolic")
iconFallback: "audio-volume-high-symbolic"
tint: root.muted ? Theme.danger : Theme.fgDim
onClicked: {
if (root.node?.audio)
root.node.audio.muted = !root.node.audio.muted;
}
WheelHandler {
onWheel: event => {
if (!root.node?.audio)
return;
const step = event.angleDelta.y > 0 ? 0.05 : -0.05;
const value = Math.max(0, Math.min(1, root.volume + step));
root.node.audio.muted = false;
root.node.audio.volume = value;
}
}
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
visible: root.output && root.selected && !root.ghost
text: root.testOpen ? "Done" : "Test"
onClicked: root.testOpen = !root.testOpen
}
// Which device is in use. A radio rather than a button, because
// choosing one is choosing all the others away.
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 18
height: 18
radius: 9
color: "transparent"
border.width: 2
border.color: root.selected ? Theme.accent : Theme.fgMuted
Rectangle {
anchors.centerIn: parent
width: 10
height: 10
radius: 5
visible: root.selected
color: Theme.accent
border.width: 0
}
}
}
Column {
anchors.left: iconTile.right
anchors.leftMargin: 13
anchors.right: trailing.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Row {
id: nameLine
width: parent.width
spacing: 8
// The name takes what the badges leave and elides into it,
// so a long device name never pushes its own badge out of
// the row.
Text {
anchors.verticalCenter: parent.verticalCenter
width: Math.min(implicitWidth, Math.max(0, nameLine.width
- (badges.width > 0 ? badges.width + nameLine.spacing : 0)))
text: root.ghost ? root.ghostLabel : AudioDevices.label(root.node)
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: root.selected ? Font.DemiBold : Font.Medium
elide: Text.ElideRight
}
Row {
id: badges
anchors.verticalCenter: parent.verticalCenter
spacing: 6
SoundBadge {
visible: root.airplay && !root.ghost
text: "AirPlay"
tone: Theme.cyan
}
SoundBadge {
visible: root.bluetooth && !root.ghost
text: "Bluetooth"
tone: Theme.accent
}
SoundBadge {
visible: root.ghost
text: "Returns when connected"
tone: Theme.fgMuted
}
}
}
Text {
width: parent.width
visible: root.subtitle !== ""
text: root.subtitle
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
HoverHandler {
id: rowHover
enabled: !root.ghost && !root.selected
cursorShape: Qt.PointingHandCursor
}
TapHandler {
enabled: !root.ghost && !root.selected
onTapped: AudioDevices.select(root.output, root.node)
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
visible: root.divider
color: Theme.alpha(Theme.fg, 0.05)
}
}
SoundChannelStrip {
width: parent.width
visible: root.testOpen && root.output && root.selected && !root.ghost
node: root.node
topPadding: 4
bottomPadding: 10
}
}
}
@@ -1,70 +1,163 @@
// Sound.
//
// One sectioned page, top to bottom: what plays, what listens, what each
// application is doing, what the machine says when it wants attention, and the
// card profiles underneath all of it. There are no tabs and nothing to apply --
// every control here writes to PipeWire or to a desktop key that other
// applications already honour, and lands the moment it is moved.
//
// The page used to end in a button that opened GNOME's Sound panel for device
// profiles. It does not any more: SoundCards reads and writes them natively, so
// there is nothing left on this subject that Panama hands to someone else.
import QtQuick
import qs.config
import qs.services
SettingsPage {
id: root
title: "Sound"
lede: "Live PipeWire output, input, and device selection."
lede: "Live PipeWire — every change lands immediately, nothing to apply."
readonly property var currentOutput: AudioDevices.current(true)
readonly property var currentInput: AudioDevices.current(false)
// Over-amplification is the whole range, not a second slider: the maximum
// moves and the region past 100% is marked.
readonly property real volumeMaximum: Settings.overAmplification ? 1.5 : 1
// The three services that read the machine rather than the graph do it once
// per page open, the way the Displays page refreshes hardware brightness.
Component.onCompleted: {
SoundFeedback.refresh();
SoundDefaults.refresh();
SoundCards.refresh();
}
SettingsCard {
title: "Output"
subtitle: AudioDevices.current(true)?.description ?? "No output device"
subtitle: root.currentOutput
? AudioDevices.label(root.currentOutput)
: "No output device"
SoundDeviceList {
width: parent.width
output: true
}
SoundVolumeRow {
label: "Volume"
node: root.currentOutput
maximum: root.volumeMaximum
}
AudioBalance {
width: parent.width
node: AudioDevices.current(true)
node: root.currentOutput
}
ToggleRow {
setting: "overAmplification"
divider: false
}
}
SettingsCard {
title: "Input"
subtitle: AudioDevices.current(false)?.description ?? "No input device"
subtitle: root.currentInput
? AudioDevices.label(root.currentInput)
: "No input device"
SoundDeviceList {
width: parent.width
output: false
}
SoundVolumeRow {
label: "Input volume"
node: root.currentInput
}
// Recording and playing back is the only honest microphone test: the
// level meter above says something is arriving, not that it is you.
ActionRow {
label: "Test your microphone"
detail: "Record a few seconds and play it back through the selected output"
divider: captureRow.visible
action: {
if (SoundTest.micTestState === "recording")
return "Recording…";
if (SoundTest.micTestState === "playing")
return "Playing back…";
return "Record & play back";
}
enabled: SoundTest.micTestState === "idle" && !!root.currentInput
onTriggered: SoundTest.startMicTest(root.currentInput, root.currentOutput)
}
SoundCaptureRow {
id: captureRow
divider: false
}
}
SettingsCard {
title: "Applications"
subtitle: "Control each application currently playing through PipeWire."
subtitle: SoundRouting.lastError !== ""
? SoundRouting.lastError
: "Each application's own level, and where it plays. Applications playing sound appear here on their own."
ApplicationMixer {
width: parent.width
}
}
// ── What the machine says when it wants attention ───────────────────────
Text {
width: parent.width
text: "Alerts & feedback"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.capitalization: Font.AllUppercase
font.letterSpacing: 0.7
topPadding: 6
}
SettingsCard {
title: "Sound feedback"
subtitle: "Use the same event preferences as GTK and GNOME applications."
title: "Alerts"
subtitle: SoundFeedback.lastError
SoundThemeRow {}
SettingRow {
label: "Event sounds"
detail: "Play alerts and interface event sounds"
controlWidth: 42
detail: "Interface sounds from GTK and GNOME applications, and Panama's notification chime"
controlWidth: 48
SettingsToggle {
anchors.fill: parent
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: SoundFeedback.eventSounds
enabled: !SoundFeedback.busy
onToggled: checked => SoundFeedback.setEventSounds(checked)
}
}
ToggleRow {
setting: "volumeChangeBlip"
}
SettingRow {
label: "Input feedback"
detail: "Play sounds for supported typing and input events"
controlWidth: 42
detail: "Key clicks and input event sounds in GTK applications"
controlWidth: 48
divider: false
SettingsToggle {
anchors.fill: parent
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: SoundFeedback.inputFeedback
enabled: !SoundFeedback.busy
onToggled: checked => SoundFeedback.setInputFeedback(checked)
@@ -72,15 +165,56 @@ SettingsPage {
}
}
SettingsCard {
title: "Advanced sound"
// ── The hardware underneath the devices ─────────────────────────────────
Text {
width: parent.width
text: "Advanced"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.capitalization: Font.AllUppercase
font.letterSpacing: 0.7
topPadding: 6
}
ActionRow {
label: "Device profiles"
detail: "Open Fedora's complete device profile panel"
divider: false
action: "Open panel"
onTriggered: SystemSettings.openGnomePanel("sound")
SettingsCard {
title: "Device profiles"
subtitle: SoundCards.lastError !== ""
? SoundCards.lastError
: "What each sound card is configured to do. Ports that are not physically connected are named, not hidden."
Repeater {
model: SoundCards.cards ?? []
OptionPickerRow {
required property var modelData
required property int index
label: String(modelData.description ?? modelData.name ?? "")
detail: String(modelData.portHint ?? "")
enabled: !SoundCards.busy
divider: index < (SoundCards.cards?.length ?? 0) - 1
current: modelData.activeProfile
options: (modelData.profiles ?? []).map(profile => ({
value: profile.name,
label: String(profile.description ?? profile.name),
detail: profile.available === false ? "Not available right now" : ""
}))
onPicked: value => SoundCards.setProfile(String(modelData.name), value)
}
}
Text {
width: parent.width
visible: (SoundCards.cards?.length ?? 0) === 0
text: SoundCards.busy ? "Reading device profiles…" : "No sound cards reported"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
horizontalAlignment: Text.AlignHCenter
topPadding: 12
bottomPadding: 12
}
}
}
@@ -0,0 +1,103 @@
// The alert sound, chosen from the sound themes installed on the machine.
//
// A theme name means nothing on paper -- "freedesktop" and "Yaru" are two words
// that sound like nothing -- so the row carries a Preview beside the value and
// plays the theme that is set right now. It is the same shape as PickerRow,
// which cannot take a second trailing control.
import QtQuick
import qs.config
import qs.services
Column {
id: root
property bool expanded: false
property bool divider: true
// [{ name, directory }] -- the display name from index.theme, and the
// directory name, which is what the gsettings key actually stores.
readonly property var themes: SoundFeedback.themes ?? []
readonly property string current: String(SoundFeedback.soundTheme ?? "")
readonly property var currentTheme:
root.themes.find(theme => String(theme.directory ?? theme.name) === root.current) ?? null
readonly property string currentLabel: root.currentTheme
? String(root.currentTheme.name ?? root.currentTheme.directory)
: root.current
width: parent ? parent.width : 620
spacing: 0
SettingRow {
id: headline
width: parent.width
label: "Alert sound"
detail: "Played by notifications — Panama's own included — and by applications that ask for attention"
activatable: root.themes.length > 0
divider: root.divider && !root.expanded
controlWidth: Math.max(210, trailingControls.implicitWidth + 8)
onActivated: root.expanded = !root.expanded
Row {
id: trailingControls
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 9
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: "Preview"
enabled: !SoundFeedback.busy
onClicked: SoundFeedback.previewAlert()
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.currentLabel
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
elide: Text.ElideRight
}
Text {
anchors.verticalCenter: parent.verticalCenter
visible: root.themes.length > 0
text: root.expanded ? "▴" : "▾"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
Column {
width: parent.width
visible: root.expanded
Repeater {
model: root.themes
TextRow {
required property var modelData
required property int index
readonly property string directory: String(modelData.directory ?? modelData.name)
width: parent.width
label: String(modelData.name ?? modelData.directory)
value: directory === root.current ? "Current" : ""
controlWidth: 90
divider: index < root.themes.length - 1
activatable: directory !== root.current
onActivated: {
SoundFeedback.setSoundTheme(directory);
root.expanded = false;
}
}
}
}
}
@@ -0,0 +1,154 @@
// The level of the device a card is about, written straight through to
// PipeWire -- there is nothing to apply, so there is nothing to commit and
// nothing to debounce.
//
// The range is a property rather than a constant because over-amplification
// extends it to 150%. The track keeps its full length either way, so the same
// slider position always means the same level; the region past 100% is marked
// in the warning tone, because that is the part that can distort.
//
// Not a SettingRow: the control stacks below the label in a narrow window
// exactly as SliderRow and GradientSliderRow do, which SettingRow's fixed
// trailing column cannot.
import QtQuick
import Quickshell.Services.Pipewire
import qs.config
import qs.widgets
Item {
id: root
property var node: null
property string label: "Volume"
property string detail: ""
property bool divider: true
property real maximum: 1
readonly property bool available: !!root.node?.audio
readonly property real volume: root.node?.audio?.volume ?? 0
readonly property bool muted: root.node?.audio?.muted ?? false
readonly property bool amplified: root.volume > 1.001
readonly property bool inline: root.width >= 520
readonly property int controlSpan: 300
width: parent ? parent.width : 620
implicitHeight: root.inline
? Math.max(56, copy.implicitHeight + 20)
: copy.implicitHeight + 32 + 30
opacity: root.available ? 1 : 0.45
// Reading or writing audio state on an untracked node silently returns
// zero, so the row binds the node it speaks for even though the device list
// above it is already tracking the same object.
PwObjectTracker {
objects: root.node ? [root.node] : []
}
function setVolume(ratio: real): void {
if (!root.node?.audio)
return;
const value = Math.max(0, Math.min(root.maximum, ratio * root.maximum));
// Moving the slider is also how you unmute, same as GNOME.
root.node.audio.muted = false;
root.node.audio.volume = value;
}
Column {
id: copy
x: 0
y: root.inline ? (root.height - height) / 2 : 10
width: root.inline ? root.width - root.controlSpan - 20 : root.width
spacing: 3
Text {
width: parent.width
text: root.label
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
elide: Text.ElideRight
}
Text {
width: parent.width
visible: root.detail !== ""
text: root.detail
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
Item {
id: control
width: root.inline ? root.controlSpan : root.width
height: 32
x: root.inline ? root.width - width : 0
y: root.inline ? (root.height - height) / 2 : copy.y + copy.height + 10
ValueSlider {
id: slider
anchors.left: parent.left
anchors.right: readout.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
enabled: root.available
value: root.muted ? 0 : root.volume / root.maximum
onMoved: ratio => root.setVolume(ratio)
}
// Everything above 100%. Drawn over the track rather than into it, so
// the slider itself stays the one the rest of the shell uses.
Rectangle {
anchors.right: slider.right
anchors.verticalCenter: slider.verticalCenter
width: root.maximum > 1 ? slider.width * (1 - 1 / root.maximum) : 0
height: 10
radius: 2
visible: root.maximum > 1
color: Theme.alpha(Theme.warn, 0.30)
border.width: 0
}
Rectangle {
anchors.verticalCenter: slider.verticalCenter
x: slider.x + slider.width / root.maximum - 1
width: 2
height: 16
radius: 1
visible: root.maximum > 1
color: Theme.alpha(Theme.warn, 0.7)
border.width: 0
}
Text {
id: readout
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 46
horizontalAlignment: Text.AlignRight
text: Math.round(root.volume * 100) + "%"
color: root.amplified ? Theme.warn : Theme.fgDim
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSize
}
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 1
visible: root.divider
color: Theme.alpha(Theme.fg, 0.065)
}
}
@@ -79,6 +79,11 @@ PasswordField 1.0 PasswordField.qml
AudioBalance 1.0 AudioBalance.qml
SoundDeviceList 1.0 SoundDeviceList.qml
SoundDeviceRow 1.0 SoundDeviceRow.qml
SoundBadge 1.0 SoundBadge.qml
SoundChannelStrip 1.0 SoundChannelStrip.qml
SoundVolumeRow 1.0 SoundVolumeRow.qml
SoundThemeRow 1.0 SoundThemeRow.qml
SoundCaptureRow 1.0 SoundCaptureRow.qml
ApplicationMixer 1.0 ApplicationMixer.qml
ApplicationVolumeRow 1.0 ApplicationVolumeRow.qml
TimeOfDayRow 1.0 TimeOfDayRow.qml
+54 -4
View File
@@ -3,6 +3,54 @@
set -u
readonly PANAMA_OSD_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly PANAMA_OSD_BLIP_DEFAULT="/usr/share/sounds/freedesktop/stereo/audio-volume-change.oga"
# ── Panama's own preferences ────────────────────────────────────────────────
#
# These run as keybinds, so the shell may not be up and there is no IPC to ask.
# settings.json is read straight off disk instead, and every failure -- no file
# on a fresh install, a file written before the key existed, a file that is not
# JSON at all -- means the schema default rather than a broken volume key.
settings_file() {
printf '%s/panama/settings.json\n' "${XDG_CONFIG_HOME:-$HOME/.config}"
}
setting_bool() {
local key="$1" fallback="$2" file value
file="$(settings_file)"
[[ -r $file ]] || { printf '%s\n' "$fallback"; return 0; }
command -v jq >/dev/null 2>&1 || { printf '%s\n' "$fallback"; return 0; }
value="$(jq -r --arg key "$key" '
if type == "object" and (.[$key] | type) == "boolean" then .[$key] else empty end
' "$file" 2>/dev/null)" || value=""
[[ $value == true || $value == false ]] || value="$fallback"
printf '%s\n' "$value"
}
# wpctl clamps to 1.0 by default, and it clamps the *result* -- so the ceiling
# has to be passed on the way down as well. Without it, stepping down from 130%
# would snap to 100% instead of 124%, which reads as the slider jumping on its
# own. The microphone never gets this: gain past 100% on a capture device buys
# noise, not signal, and the Sound page's input slider stays 0-100 to match.
volume_limit() {
if [[ $(setting_bool overAmplification false) == true ]]; then
printf '1.5\n'
else
printf '1\n'
fi
}
# The click that says the volume moved. Backgrounded and never waited on: it is
# feedback about something that has already happened, so it must not sit between
# the key press and the OSD. A distribution without the freedesktop sound theme
# has no file to play, which is a silent desktop rather than a broken key.
play_blip() {
local sound="${PANAMA_OSD_BLIP_SOUND:-$PANAMA_OSD_BLIP_DEFAULT}"
[[ $(setting_bool volumeChangeBlip true) == true ]] || return 0
[[ -f $sound ]] || return 0
pw-play "$sound" >/dev/null 2>&1 &
disown 2>/dev/null || true
}
strict_delivery() {
[[ ${PANAMA_OSD_STRICT:-false} == true || ${PANAMA_OSD_STRICT:-false} == 1 ]]
@@ -48,13 +96,15 @@ show_volume() {
}
adjust_volume() {
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SINK@"
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SINK@" limit
limit="$(volume_limit)"
case "$action" in
up) wpctl set-volume -l 1 "$target" "${step}%+" || return ;;
down) wpctl set-volume "$target" "${step}%-" || return ;;
up) wpctl set-volume -l "$limit" "$target" "${step}%+" || return ;;
down) wpctl set-volume -l "$limit" "$target" "${step}%-" || return ;;
toggle) wpctl set-mute "$target" toggle || return ;;
*) printf 'Usage: panama-osd volume up|down|toggle [step]\n' >&2; return 2 ;;
esac
play_blip
show_volume "$target" volume
}
@@ -62,7 +112,7 @@ adjust_microphone() {
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SOURCE@"
case "$action" in
up) wpctl set-volume -l 1 "$target" "${step}%+" || return ;;
down) wpctl set-volume "$target" "${step}%-" || return ;;
down) wpctl set-volume -l 1 "$target" "${step}%-" || return ;;
toggle) wpctl set-mute "$target" toggle || return ;;
*) printf 'Usage: panama-osd microphone up|down|toggle [step]\n' >&2; return 2 ;;
esac
@@ -27,6 +27,16 @@ Singleton {
readonly property var applications: AudioStreams.group(
root.playbackStreams, PwNodeType.AudioOutStream)
// The capture side of the same story: which applications currently hold the
// microphone open. Same grouping rules as playback, so an application that
// records on three streams reads as one entry with one mute.
readonly property var captureStreams: Pipewire.nodes.values.filter(node =>
node.ready && node.audio
&& (node.type & PwNodeType.AudioInStream) === PwNodeType.AudioInStream)
readonly property var captureApplications: AudioStreams.group(
root.captureStreams, PwNodeType.AudioInStream)
function nodes(output: bool): var {
return output ? root.outputs : root.inputs;
}
@@ -58,8 +68,11 @@ Singleton {
return AudioStreams.muted(application);
}
function setApplicationVolume(application: var, value: real): bool {
return AudioStreams.setVolume(application, value);
// `max` is the clamp ceiling; omitting it means 1, and anything that is not
// a positive number is treated as omitted. Callers that honor the
// over-amplification setting pass 1.5.
function setApplicationVolume(application: var, value: real, max: real): bool {
return AudioStreams.setVolume(application, value, max);
}
function setApplicationMuted(application: var, muted: bool): bool {
+10 -3
View File
@@ -54,9 +54,16 @@ function muted(application) {
return nodes.length > 0 && nodes.every(node => node.audio.muted === true);
}
function setVolume(application, value) {
const next = Math.max(0, Math.min(1, Number(value)));
if (!Number.isFinite(next)) return false;
// `max` is the ceiling the value is clamped to, defaulting to 1. It is a
// parameter rather than a constant so over-amplification (1.5) stays a caller's
// decision: this file is deliberately free of any Settings dependency so it can
// be reasoned about -- and tested -- as plain JavaScript.
function setVolume(application, value, max) {
const requested = Number(value);
if (!Number.isFinite(requested)) return false;
const ceiling = Number(max);
const bound = Number.isFinite(ceiling) && ceiling > 0 ? ceiling : 1;
const next = Math.max(0, Math.min(bound, requested));
const nodes = audioNodes(application);
for (const node of nodes) {
node.audio.muted = false;
+35
View File
@@ -15,6 +15,7 @@ pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import Quickshell.Io
import Quickshell.Services.Notifications
import QtQuick
import qs.config
@@ -232,6 +233,7 @@ Singleton {
// because FocusModes.allows is false whenever no mode is active.
if (!root.doNotDisturb || FocusModes.allows(root.notificationAppId(notification))) {
root.popups = [notification].concat(root.popups);
root.playBell(notification);
} else if (notification.transient) {
// Never shown, and (being transient) never filed in history
// either — nothing will otherwise dismiss() it, so schedule
@@ -240,6 +242,39 @@ Singleton {
}
}
// ── The chime ───────────────────────────────────────────────────────────
//
// A notification you can hear. GTK applications get this from libcanberra
// for free; Panama's own popups had no sound at all, so a notification that
// arrived while you were looking elsewhere simply did not happen. Same
// theme, same desktop preference: one switch covers the whole session
// rather than leaving Panama as the one thing that stays quiet -- or the
// one thing that will not shut up.
//
// Throttled to one bell a second. A burst -- a chat catching up after a
// suspend, ten build jobs finishing together -- would otherwise stack a
// dozen overlapping bells, which is a noise rather than a notification.
property real lastBellAt: 0
function playBell(notification: var): void {
if (!SoundFeedback.eventSounds)
return;
// Low urgency is the "you did not need to know this" tier -- battery
// reaching full, a sync completing. It stays silent by design.
if (notification.urgency === NotificationUrgency.Low)
return;
const now = Date.now();
if (bell.running || now - root.lastBellAt < 1000)
return;
root.lastBellAt = now;
bell.command = SoundFeedback.bellCommand;
bell.running = true;
}
Process { id: bell }
// Runs a DND-hidden transient notification through the same lifetime it
// would have gotten as a visible popup (Toast.qml's countdown), just
// without ever showing it, so it still gets released instead of staying
@@ -59,7 +59,8 @@ Singleton {
"notifications": "notifications",
"capture": "screen-intelligence",
"gaming": "gaming",
"search": "applications"
"search": "applications",
"sound": "sound"
})
// Settings that are real but have no schema entry, because the system owns
@@ -126,6 +127,13 @@ Singleton {
{ label: "Input volume", detail: "Choose the microphone and its level", page: "sound" },
{ label: "Per-application volume", detail: "Set the level of each application separately", page: "sound" },
{ label: "Event sounds", detail: "Play alerts and interface event sounds", page: "sound" },
{ label: "Balance", detail: "Shift the output between the left and right speaker", page: "sound" },
{ label: "Speaker test", detail: "Play a tone through each channel to find which speaker is which", page: "sound" },
{ label: "Microphone test", detail: "Record a few seconds and play it straight back", page: "sound" },
{ label: "Alert sound", detail: "The sound theme alerts and Panama's notification chime use", page: "sound" },
{ label: "Applications using the microphone", detail: "What is listening right now, and muting it", page: "sound" },
{ label: "Move an application's audio", detail: "Send one application to a different output device", page: "sound" },
{ label: "Device profiles", detail: "Switch a sound card between stereo, surround, and headset modes", page: "sound" },
{ label: "Saved passwords", detail: "The login keyring and what is stored in it", page: "privacy" },
{ label: "Camera and microphone", detail: "Which applications may use them", page: "privacy" },
{ label: "Screen sharing", detail: "Which applications may capture the screen", page: "privacy" },
@@ -0,0 +1,162 @@
pragma Singleton
// Native device profiles -- the sound card's own idea of what it can be.
//
// A card is one piece of hardware; a profile is one wiring of it. The rear
// Realtek can be analog stereo out, or S/PDIF out, or duplex with the line-in
// live, but only one at a time, and switching costs the sinks and sources the
// old profile provided. That choice is not a PipeWire node property, so
// Quickshell's PipeWire bindings cannot see it at all -- which is why this file
// shells out where AudioDevices must not.
//
// `pactl -f json list cards` is the read side (verified against pactl 17 /
// pipewire-pulse, which prints profiles and ports as objects keyed by name, not
// as arrays). `pactl set-card-profile` is the write side. Both talk to
// pipewire-pulse, which owns card state -- so nothing here races the PipeWire
// node objects AudioDevices holds.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
// [{ name, description, activeProfile,
// profiles: [{ name, description, available }], portHint }]
property var cards: []
property string lastError: ""
readonly property bool busy: lister.running || writer.running
// True once a listing has completed, however it went, so the UI can tell
// "not looked yet" apart from "no cards on this machine".
property bool loaded: false
// A test seam, matching scripts/panama-brightness: point this at a file
// holding canned `pactl -f json list cards` output and the service parses
// that instead of the live daemon.
readonly property string fixturePath: Quickshell.env("PANAMA_SOUND_CARDS_FIXTURE") || ""
function refresh(): void {
if (lister.running)
return;
lister.command = root.fixturePath !== ""
? ["cat", root.fixturePath]
: ["pactl", "-f", "json", "list", "cards"];
lister.running = true;
}
function cardFor(name: string): var {
return root.cards.find(card => card.name === name) ?? null;
}
// Switching profile is immediate and lossy: the sinks the old profile
// provided disappear. The list is re-read afterwards rather than assumed,
// because a card may refuse a profile its ports cannot currently support.
function setProfile(cardName: string, profileName: string): void {
const card = String(cardName ?? "").trim();
const profile = String(profileName ?? "").trim();
if (card === "" || profile === "" || writer.running)
return;
writer.command = ["pactl", "set-card-profile", card, profile];
writer.running = true;
}
// The most useful single line about where sound physically comes out.
//
// Port availability is the only part of a card's state that answers "is
// anything plugged in", and it is per-port, so the highest-priority
// available port is the one worth naming. A card whose ports all report
// "availability unknown" -- USB devices, mostly -- gets no line rather than
// a guess, because "unknown" is not "disconnected".
function portHintFor(ports: var): string {
let best = null;
let anyKnown = false;
for (const key in ports ?? {}) {
const port = ports[key];
const availability = String(port?.availability ?? "");
if (availability === "available" || availability === "not available")
anyKnown = true;
if (availability !== "available")
continue;
if (!best || Number(port.priority ?? 0) > Number(best.priority ?? 0))
best = port;
}
if (best)
return `${String(best.description ?? "Port").trim()} connected`;
return anyKnown ? "No port connected" : "";
}
function parse(text: string): void {
let parsed;
try {
parsed = JSON.parse(text);
} catch (error) {
root.cards = [];
root.lastError = "Device profiles could not be read.";
root.loaded = true;
return;
}
if (!Array.isArray(parsed)) {
root.cards = [];
root.lastError = "Device profiles could not be read.";
root.loaded = true;
return;
}
root.cards = parsed.map(card => {
const properties = card.properties ?? {};
const profiles = [];
for (const key in card.profiles ?? {}) {
const profile = card.profiles[key];
profiles.push({
name: key,
description: String(profile?.description ?? key),
available: profile?.available !== false,
priority: Number(profile?.priority ?? 0)
});
}
// pactl prints profiles in an object, so their order is whatever
// the daemon happened to build. Priority is the order PulseAudio
// and GNOME both present them in, and it puts "Off" last for free.
profiles.sort((a, b) => b.priority - a.priority);
return {
name: String(card.name ?? ""),
description: String(properties["device.description"]
?? properties["device.nick"]
?? card.name
?? "Sound card"),
activeProfile: String(card.active_profile ?? ""),
profiles: profiles,
portHint: root.portHintFor(card.ports)
};
}).filter(card => card.name !== "");
root.lastError = "";
root.loaded = true;
}
Process {
id: lister
stdout: StdioCollector {
onStreamFinished: root.parse(this.text)
}
onExited: (code, status) => {
if (code !== 0) {
root.cards = [];
root.lastError = "Device profiles could not be read.";
root.loaded = true;
}
}
}
Process {
id: writer
onExited: (code, status) => {
root.lastError = code === 0 ? "" : "That device profile could not be applied.";
root.refresh();
}
}
}
@@ -0,0 +1,189 @@
pragma Singleton
// What you *asked* for, as opposed to what you got.
//
// PipeWire keeps two different answers to "which is the default output". The
// effective one -- the sink audio is actually reaching right now -- is what
// Quickshell's `Pipewire.defaultAudioSink` exposes. The configured one is the
// device you last chose, remembered by name, and it survives that device being
// switched off, unpaired, or carried into another room.
//
// The two disagree constantly, and the disagreement is the whole story a sound
// page should be telling. Choose the Bluetooth headphones, walk away, and the
// configured default stays those headphones while the effective default falls
// back to the speakers. A list that only renders present devices shows the
// speakers selected and no trace of the headphones, which reads as "Panama
// forgot" rather than "they are out of range".
//
// So this service reads the configured names and says, plainly, when one of
// them maps to nothing that is currently here. The UI renders that as a ghost
// row: visible, dimmed, not selectable, labelled "Returns when connected".
//
// Read through pw-metadata rather than wpctl. `wpctl status` prints the
// *effective* defaults only -- the configured name appears nowhere in its
// output -- so it cannot answer the question this file exists to answer.
// `pw-metadata -n default 0` prints both, exits immediately, and mutates
// nothing.
//
// Quickshell's `Pipewire.preferredDefaultAudioSink` is the same configured
// value, but typed as a node pointer, so it reads null in exactly the case that
// matters: the configured device is not here, and there is no node to point at.
// A name survives where a pointer cannot, which is why the string is read
// directly.
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire
import QtQuick
Singleton {
id: root
// The names PipeWire remembers as chosen. Empty until the first read
// lands, and empty on a session that has never had a default set by hand.
property string configuredSinkName: ""
property string configuredSourceName: ""
// The names audio is actually reaching. Kept alongside the configured pair
// purely so the two can be compared without a second source of truth.
property string effectiveSinkName: ""
property string effectiveSourceName: ""
property string lastError: ""
readonly property bool busy: reader.running
// True once a read has completed, however it went -- so the UI can tell
// "not looked yet" apart from "looked, and nothing is configured".
property bool loaded: false
// A test seam, matching scripts/panama-brightness: point this at a file
// holding canned pw-metadata output and the service parses that instead of
// talking to the live graph.
readonly property string fixturePath: Quickshell.env("PANAMA_SOUND_DEFAULTS_FIXTURE") || ""
readonly property var presentNames: {
const names = {};
for (const node of Pipewire.nodes.values) {
if (node && !node.isStream && node.name)
names[String(node.name)] = true;
}
return names;
}
// The configured device that is not here, or null. This is exactly what the
// ghost row renders; `null` means every configured default is present and
// the list has nothing extra to say.
readonly property var absentSink: root.configuredSinkName !== ""
&& root.presentNames[root.configuredSinkName] !== true
? ({ name: root.configuredSinkName, label: root.label(root.configuredSinkName) })
: null
readonly property var absentSource: root.configuredSourceName !== ""
&& root.presentNames[root.configuredSourceName] !== true
? ({ name: root.configuredSourceName, label: root.label(root.configuredSourceName) })
: null
function absent(output: bool): var {
return output ? root.absentSink : root.absentSource;
}
function refresh(): void {
if (reader.running)
return;
reader.command = root.fixturePath !== ""
? ["cat", root.fixturePath]
: ["pw-metadata", "-n", "default", "0"];
reader.running = true;
}
// A node name turned into something a person can read.
//
// An absent device has no description to borrow -- descriptions live on the
// node, and the node is gone -- so the stored name is all there is. These
// names are structured, though, and the structure carries the useful part:
// the Bluetooth address, the AirPlay speaker's hostname, the USB device's
// product string. Anything this does not recognise is tidied rather than
// guessed at, because a wrong name is worse than an ugly one.
function label(name: string): string {
const raw = String(name ?? "").trim();
if (raw === "")
return "";
// bluez_output.74_15_F5_13_A4_28.1 -- nothing but the address.
const bluetooth = raw.match(/^bluez_[a-z]+\.([0-9A-Fa-f]{2}(?:_[0-9A-Fa-f]{2}){5})/);
if (bluetooth)
return `Bluetooth device (${bluetooth[1].replace(/_/g, ":").toUpperCase()})`;
// raop_sink.Living-Room.local.192.168.1.162.7000 -- the mDNS hostname.
const airplay = raw.match(/^raop_[a-z]+\.(.+?)\.local\b/);
if (airplay)
return airplay[1].replace(/[-_]+/g, " ").trim();
// alsa_output.usb-Generic_USB_Audio-00.analog-stereo -- the udev bus id
// carries the product string, with a two-digit interface number glued
// on the end that means nothing to anyone.
const usb = raw.match(/^alsa_[a-z]+\.usb-(.+?)(?:-[0-9]{2})?\.[^.]*$/);
if (usb)
return usb[1].replace(/_+/g, " ").replace(/\s+/g, " ").trim();
const tidied = raw.replace(/^[a-z0-9]+_[a-z]+\./, "")
.replace(/[._]+/g, " ")
.replace(/\s+/g, " ")
.trim();
return tidied || raw;
}
// pw-metadata prints one line per key:
// update: id:0 key:'default.audio.sink' value:'{"name":"..."}' type:'...'
// The value is JSON, and carries `name` -- and, on some setups, a
// `description` PipeWire stored alongside it, which is a better label than
// anything derivable from the name.
function parse(text: string): void {
const found = {};
for (const line of String(text ?? "").split("\n")) {
const matched = line.match(/key:'([^']+)'\s+value:'(.*)'\s+type:'/);
if (!matched)
continue;
try {
const value = JSON.parse(matched[2]);
const name = String(value?.name ?? "").trim();
if (name !== "")
found[matched[1]] = name;
} catch (error) {
// A key whose value is not JSON is not our key. Skip it rather
// than failing the whole read.
}
}
root.configuredSinkName = found["default.configured.audio.sink"] ?? "";
root.configuredSourceName = found["default.configured.audio.source"] ?? "";
root.effectiveSinkName = found["default.audio.sink"] ?? "";
root.effectiveSourceName = found["default.audio.source"] ?? "";
root.loaded = true;
}
Process {
id: reader
stdout: StdioCollector {
onStreamFinished: root.parse(this.text)
}
onExited: (code, status) => {
root.lastError = code === 0
? ""
: "PipeWire's remembered default devices could not be read.";
if (code !== 0)
root.loaded = true;
}
}
// The effective default moving is the signal that the configured one may
// now be unreachable (or reachable again). Re-reading on that change is
// what keeps the ghost row honest without polling.
Connections {
target: Pipewire
function onDefaultAudioSinkChanged(): void { root.refresh(); }
function onDefaultAudioSourceChanged(): void { root.refresh(); }
}
Component.onCompleted: root.refresh()
}
@@ -1,8 +1,14 @@
pragma Singleton
// GNOME and GTK applications already honor these desktop sound preferences.
// Panama controls the same durable keys so moving between sessions does not
// create two competing notions of whether event feedback is enabled.
// GNOME and GTK applications already honor these desktop sound preferences --
// whether event feedback plays, whether typing clicks, and which sound theme
// those samples come from. Panama controls the same durable keys so moving
// between sessions does not create two competing notions of any of it, and
// plays its own notification chime out of the same theme (see Notifs.qml).
//
// There is deliberately no alert *volume* here: the desktop sound theme has no
// volume channel of its own, and a slider that changed nothing would be worse
// than no slider at all.
import Quickshell
import Quickshell.Io
@@ -13,9 +19,58 @@ Singleton {
property bool eventSounds: true
property bool inputFeedback: false
// The XDG sound theme, by directory name. "freedesktop" is the one every
// distribution ships and the fallback everything else is layered over.
property string soundTheme: "freedesktop"
property string lastError: ""
readonly property bool busy: eventRead.running || inputRead.running
|| eventWrite.running || inputWrite.running
|| themeRead.running || eventWrite.running || inputWrite.running
|| themeWrite.running
// [{ directory, name }] -- the directory is what gsettings stores, the name
// is what the theme calls itself in its index.theme.
property var themes: [{ directory: "freedesktop", name: "Default" }]
// ── The alert sound ─────────────────────────────────────────────────────
//
// A theme is a directory of samples with a fallback chain, not a single
// file, and a theme that overrides only a handful of sounds is normal. So
// the bell is resolved as a list of candidates in preference order --
// user-installed theme, system theme, freedesktop -- and the first one that
// exists is played. Resolving it in the shell rather than here keeps this
// free of file probing on a hot path.
readonly property string homeDir: Quickshell.env("HOME") || ""
readonly property var bellCandidates: [
root.homeDir !== ""
? `${root.homeDir}/.local/share/sounds/${root.soundTheme}/stereo/bell.oga` : "",
`/usr/share/sounds/${root.soundTheme}/stereo/bell.oga`,
"/usr/share/sounds/freedesktop/stereo/bell.oga"
].filter((path, index, all) => path !== "" && all.indexOf(path) === index)
// The argv that plays the current theme's bell once, or nothing at all if
// no candidate exists. Shared with Notifs.qml, which plays the same bell
// for Panama's own notification popups.
readonly property var bellCommand: ["sh", "-c",
'for candidate in "$@"; do [ -f "$candidate" ] && exec pw-play "$candidate"; done; exit 0',
"qs-sound-feedback"].concat(root.bellCandidates)
function previewAlert(): void {
if (preview.running)
return;
preview.command = root.bellCommand;
preview.running = true;
}
function setSoundTheme(name: string): void {
const theme = String(name ?? "").trim();
if (theme === "")
return;
root.soundTheme = theme;
root._writeSoundTheme();
}
function parsedBoolean(text: string, fallback: bool): bool {
const value = text.trim();
@@ -31,6 +86,10 @@ Singleton {
eventRead.running = true;
if (!inputRead.running)
inputRead.running = true;
if (!themeRead.running)
themeRead.running = true;
if (!themeScan.running)
themeScan.running = true;
}
function setEventSounds(enabled: bool): void {
@@ -64,6 +123,20 @@ Singleton {
inputWrite.running = true;
}
function _writeSoundTheme(): void {
if (themeWrite.running)
return;
themeWrite.writtenValue = root.soundTheme;
themeWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "theme-name", root.soundTheme];
themeWrite.running = true;
}
// gsettings prints strings quoted: 'freedesktop'.
function parsedString(text: string, fallback: string): string {
const value = text.trim().replace(/^'(.*)'$/, "$1");
return value === "" ? fallback : value;
}
Process {
id: eventRead
command: ["gsettings", "get", "org.gnome.desktop.sound", "event-sounds"]
@@ -118,5 +191,70 @@ Singleton {
}
}
Process {
id: themeRead
command: ["gsettings", "get", "org.gnome.desktop.sound", "theme-name"]
stdout: StdioCollector {
onStreamFinished: root.soundTheme = root.parsedString(this.text, root.soundTheme)
}
onExited: (code, status) => {
if (code !== 0)
root.lastError = "The alert sound theme could not be read.";
}
}
Process {
id: themeWrite
property string writtenValue: "freedesktop"
onExited: (code, status) => {
if (code !== 0) {
root.lastError = "The alert sound theme could not be changed.";
root.refresh();
} else {
root.lastError = "";
}
if (root.soundTheme !== themeWrite.writtenValue)
root._writeSoundTheme();
}
}
// Installed themes, from the index.theme every XDG sound theme must carry.
// Emitted as directory<TAB>name so the pair cannot drift apart; a theme
// whose index.theme has no Name= falls back to its directory rather than
// being dropped, because it is still selectable and still works.
Process {
id: themeScan
command: ["sh", "-c", `
for theme in "$HOME"/.local/share/sounds/*/index.theme /usr/share/sounds/*/index.theme; do
[ -f "$theme" ] || continue
directory=$(basename "$(dirname "$theme")")
name=$(sed -n 's/^Name=//p' "$theme" | head -n1)
[ -n "$name" ] || name=$directory
printf '%s\\t%s\\n' "$directory" "$name"
done
`]
stdout: StdioCollector {
onStreamFinished: {
const found = [];
const seen = {};
for (const line of String(this.text).split("\n")) {
const parts = line.split("\t");
const directory = String(parts[0] ?? "").trim();
if (directory === "" || seen[directory])
continue;
seen[directory] = true;
found.push({ directory, name: String(parts[1] ?? "").trim() || directory });
}
// Never hand the UI an empty picker: freedesktop is the theme
// every fallback chain ends at, present or not.
if (!seen["freedesktop"])
found.push({ directory: "freedesktop", name: "Default" });
root.themes = found;
}
}
}
Process { id: preview }
Component.onCompleted: root.refresh()
}
@@ -0,0 +1,160 @@
pragma Singleton
// Sending one application's audio to a different output.
//
// PipeWire routes a playback stream either by following the system default or
// by a per-stream target the session manager remembers. Quickshell's PipeWire
// bindings expose the graph but not that routing decision, so moving a stream
// means shelling out -- which is why this lives beside AudioDevices rather than
// inside it, on the same reasoning as SoundTest.
//
// ── Reading: native ─────────────────────────────────────────────────────────
// `currentSinkFor` walks `Pipewire.linkGroups`, which already says which sink a
// stream's links land on. Nothing is shelled out to read.
//
// It reports "" -- follows the default -- whenever the stream is linked to the
// sink that is currently the default. That is a deliberate simplification: an
// application explicitly pinned to the device that also happens to be the
// default is indistinguishable from one that is merely following it, and the
// difference is inaudible for as long as it lasts. The moment the default moves
// away, the pinned stream stays put and starts reporting its real sink, which
// is the case the picker actually needs to get right. The alternative --
// polling `pw-metadata` for per-node target keys -- buys a distinction nobody
// can hear, at the cost of a timer.
//
// ── Writing: two different mechanisms, deliberately ─────────────────────────
// `moveApplication` uses `pactl move-sink-input <serial> <sink>`. The serial is
// `object.serial`, which is exactly what pipewire-pulse presents as a PulseAudio
// index (verified: `pactl -f json list` prints `index` equal to the object's
// `object.serial`), so no id translation is needed.
//
// `routeToDefault` cannot use the same call, because moving a stream *to* the
// default sink pins it there -- it would stop following, and quietly stay behind
// the next time the default changed. Pinning is stored as a `target.object` key
// on the stream's node id in PipeWire's "default" metadata, so releasing it is
// deleting that key: `pw-metadata -n default -d <node-id> target.object`. The
// session manager sees the metadata change and re-links the stream to whatever
// the default is now. The legacy `target.node` key is deleted alongside it,
// because streams pinned by older tooling carry that one instead.
//
// Note the id asymmetry: pactl speaks object serials, pw-metadata speaks node
// ids. They are different numbers for the same stream.
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire
import QtQuick
Singleton {
id: root
property string lastError: ""
readonly property bool busy: mover.running || root.queue.length > 0
// Pending commands, run one at a time. An application on three streams is
// three calls, and letting them overlap means three processes racing for
// the same metadata.
property var queue: []
function streamSerial(node: var): string {
const serial = String(node?.properties?.["object.serial"] ?? "").trim();
return serial !== "" ? serial : String(node?.id ?? "");
}
// Name of the sink this application's audio is reaching, or "" when it is
// simply following the system default. See the note above on why those two
// answers merge while the default *is* that sink.
function currentSinkFor(group: var): string {
const nodes = group?.nodes ?? [];
if (nodes.length === 0)
return "";
const stream = nodes[0];
const defaultSink = Pipewire.defaultAudioSink;
for (const linkGroup of Pipewire.linkGroups.values) {
if (linkGroup?.source !== stream || !linkGroup?.target)
continue;
if (defaultSink && linkGroup.target === defaultSink)
return "";
return String(linkGroup.target.name ?? "");
}
return "";
}
// Pin every stream of this application to one sink.
function moveApplication(group: var, sinkName: string): void {
const sink = String(sinkName ?? "").trim();
const nodes = group?.nodes ?? [];
if (sink === "" || nodes.length === 0)
return;
const commands = [];
for (const node of nodes) {
const serial = root.streamSerial(node);
if (serial !== "")
commands.push(["pactl", "move-sink-input", serial, sink]);
}
root.enqueue(commands, "That application's audio could not be moved.");
}
// Release the pin, so the application follows the system default again.
//
// Mechanism: delete the stream's routing key from PipeWire's "default"
// metadata -- `pw-metadata -n default -d <node-id> target.object`, plus the
// legacy `target.node` for streams older tooling pinned. The session
// manager re-links on the metadata change.
//
// Deliberately NOT `pactl move-sink-input <serial> <default sink>`: that
// writes the pin rather than clearing it, so the application would sit on
// today's default forever and quietly stop following tomorrow's.
//
// Note the id asymmetry -- pw-metadata takes the node id, pactl takes the
// object serial, and they are different numbers for the same stream.
function routeToDefault(group: var): void {
const nodes = group?.nodes ?? [];
if (nodes.length === 0)
return;
const commands = [];
for (const node of nodes) {
const id = String(node?.id ?? "").trim();
if (id === "")
continue;
commands.push(["pw-metadata", "-n", "default", "-d", id, "target.object"]);
commands.push(["pw-metadata", "-n", "default", "-d", id, "target.node"]);
}
root.enqueue(commands, "That application could not be returned to the default output.");
}
function enqueue(commands: var, failureMessage: string): void {
if (commands.length === 0)
return;
root.lastError = "";
root.queue = root.queue.concat(commands.map(command => ({
command: command,
failureMessage: failureMessage
})));
root.pump();
}
property string pendingFailure: ""
function pump(): void {
if (mover.running || root.queue.length === 0)
return;
const next = root.queue[0];
root.queue = root.queue.slice(1);
root.pendingFailure = next.failureMessage;
mover.command = next.command;
mover.running = true;
}
Process {
id: mover
onExited: (code, status) => {
if (code !== 0)
root.lastError = root.pendingFailure;
root.pump();
}
}
}
+174 -9
View File
@@ -1,40 +1,205 @@
pragma Singleton
// Playing a short sound out of one chosen output.
// Playing a short sound out of one chosen output, and hearing your own
// microphone back.
//
// Deliberately not part of AudioDevices, which owns device state through the
// Quickshell PipeWire bindings and must not shell out -- doing so there would
// race the service that owns those same objects. This spawns a short-lived
// playback client instead: it creates its own stream and mutates no device, so
// there is nothing for it to race.
// race the service that owns those same objects. This spawns short-lived
// playback and capture clients instead: they create their own streams and
// mutate no device, so there is nothing for them to race.
//
// It exists because nine outputs named after their chipsets cannot be told
// apart by reading. The only way to know which is which is to hear one.
// apart by reading. The only way to know which is which is to hear one -- and
// on a device with more than two channels, the only way to know the rear pair
// is wired the right way round is to hear each channel on its own.
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire
import QtQuick
Singleton {
id: root
readonly property string sampleDir: "/usr/share/sounds/freedesktop/stereo"
// A short, unmistakable, front-and-centre sample that ships with the
// freedesktop sound theme, so nothing has to be bundled.
readonly property string sample:
"/usr/share/sounds/freedesktop/stereo/audio-channel-front-center.oga"
readonly property string sample: `${root.sampleDir}/audio-channel-front-center.oga`
readonly property bool playing: player.running
// The channel currently being played, "" when nothing is. Drives the lit
// chip in the channel strip.
property string playingChannel: ""
// ── Channels ────────────────────────────────────────────────────────────
//
// The freedesktop theme ships one spoken sample per channel, and the set it
// ships is the set that can be tested. A channel with no sample on disk --
// the LFE, chiefly, which has no name to speak -- is left out of the strip
// rather than given a chip that would do nothing when pressed.
readonly property var channelSamples: {
const samples = {};
samples[PwAudioChannel.Mono] = { name: "Mono", label: "Mono", file: "audio-channel-front-center" };
samples[PwAudioChannel.FrontLeft] = { name: "FrontLeft", label: "Front Left", file: "audio-channel-front-left" };
samples[PwAudioChannel.FrontRight] = { name: "FrontRight", label: "Front Right", file: "audio-channel-front-right" };
samples[PwAudioChannel.FrontCenter] = { name: "FrontCenter", label: "Center", file: "audio-channel-front-center" };
samples[PwAudioChannel.SideLeft] = { name: "SideLeft", label: "Side Left", file: "audio-channel-side-left" };
samples[PwAudioChannel.SideRight] = { name: "SideRight", label: "Side Right", file: "audio-channel-side-right" };
samples[PwAudioChannel.RearLeft] = { name: "RearLeft", label: "Rear Left", file: "audio-channel-rear-left" };
samples[PwAudioChannel.RearRight] = { name: "RearRight", label: "Rear Right", file: "audio-channel-rear-right" };
samples[PwAudioChannel.RearCenter] = { name: "RearCenter", label: "Rear Center", file: "audio-channel-rear-center" };
return samples;
}
// Ordered [{ name, label }] for one device, in the order PipeWire reports
// its channels -- which is the order they are physically wired, so the
// strip reads left to right the way the speakers stand.
function channelsFor(node: var): var {
const channels = node?.audio?.channels ?? [];
const out = [];
for (const channel of channels) {
const known = root.channelSamples[channel];
if (known && !out.some(entry => entry.name === known.name))
out.push({ name: known.name, label: known.label });
}
return out;
}
function sampleFor(channelName: string): string {
for (const key in root.channelSamples) {
const known = root.channelSamples[key];
if (known.name === channelName)
return `${root.sampleDir}/${known.file}.oga`;
}
return "";
}
// Targeted by node name taken straight from the live node. pw-play falls
// back to the default output for a target it cannot find, so a stale name
// would play out of the wrong device and look like the test had worked.
function play(node: var): void {
root.playSample(node, root.sample, "");
}
function playChannel(node: var, channelName: string): void {
const file = root.sampleFor(String(channelName ?? ""));
if (file === "")
return;
root.playSample(node, file, String(channelName));
}
function playSample(node: var, file: string, channelName: string): void {
const target = String(node?.name ?? "");
if (target === "" || player.running)
return;
player.command = ["pw-play", "--target", target, root.sample];
root.playingChannel = channelName;
player.command = ["pw-play", "--target", target, file];
player.running = true;
}
Process { id: player }
Process {
id: player
onExited: (code, status) => root.playingChannel = ""
}
// ── Microphone test ─────────────────────────────────────────────────────
//
// Record a few seconds, then play them straight back. A level meter proves
// the microphone is producing samples; only hearing yourself proves it is
// producing *you*, at a usable level, through the output you are wearing.
//
// "idle" -> "recording" -> "playing" -> "idle". One file, in the runtime
// directory, overwritten every run: recordings of the user are not
// something to leave lying around, and the runtime directory is cleared
// when the session ends.
readonly property string micTestPath:
`${Quickshell.env("XDG_RUNTIME_DIR") || "/tmp"}/panama-mic-test.wav`
property string micTestState: "idle"
readonly property int micTestSeconds: 3
readonly property int micTestRate: 48000
function startMicTest(sourceNode: var, sinkNode: var): void {
const source = String(sourceNode?.name ?? "");
if (source === "" || root.micTestState !== "idle")
return;
// pw-play needs a target too, or the playback lands on the default
// output rather than the one being looked at.
root.micTestSink = String(sinkNode?.name ?? "");
// `-n` makes pw-record stop itself after exactly this many samples and
// close the file cleanly, which is what leaves a playable WAV header
// behind. The timer below is the watchdog for a capture device that
// never produces a sample at all, where the count would never be
// reached and the test would hang in "recording" forever.
recorder.command = [
"pw-record", "--target", source,
"--rate", String(root.micTestRate),
"-n", String(root.micTestRate * root.micTestSeconds),
root.micTestPath
];
root.micTestState = "recording";
recorder.running = true;
micTestWatchdog.restart();
}
function cancelMicTest(): void {
micTestWatchdog.stop();
// Only arm the cancelled flag if something is actually going to exit
// and read it -- otherwise it would survive to poison the next run.
root.micTestCancelled = recorder.running || playback.running;
root.micTestState = "idle";
if (recorder.running)
recorder.signal(15);
if (playback.running)
playback.signal(15);
}
property string micTestSink: ""
property bool micTestCancelled: false
Timer {
id: micTestWatchdog
// Comfortably past the sample count, so it only ever fires for a
// capture that has stalled rather than one that is merely slow.
interval: (root.micTestSeconds + 3) * 1000
onTriggered: {
if (recorder.running)
recorder.signal(15);
}
}
Process {
id: recorder
onExited: (code, status) => {
micTestWatchdog.stop();
if (root.micTestCancelled) {
root.micTestCancelled = false;
root.micTestState = "idle";
return;
}
if (code !== 0) {
root.micTestState = "idle";
return;
}
root.micTestState = "playing";
playback.command = root.micTestSink !== ""
? ["pw-play", "--target", root.micTestSink, root.micTestPath]
: ["pw-play", root.micTestPath];
playback.running = true;
}
}
Process {
id: playback
onExited: (code, status) => {
root.micTestCancelled = false;
root.micTestState = "idle";
}
}
}
+28 -1
View File
@@ -1,5 +1,10 @@
// Read-only contract harness for the Sound page. It instantiates every device
// row against the real PipeWire graph but exposes no mutating IPC methods.
//
// The two services that read the machine rather than the graph -- SoundDefaults
// and SoundCards -- are fed through their own fixture seams by the contract, so
// the ghost row and the profile card can be measured against canned state
// instead of against whatever hardware is plugged in today.
import Quickshell
import Quickshell.Io
@@ -34,6 +39,20 @@ ShellRoot {
height: 900
}
// A device list of its own, so the ghost row can be read without reaching
// into the page's internals.
SoundDeviceList {
id: outputList
width: 620
visible: false
output: true
}
SoundCaptureRow {
id: captureProbe
width: 620
}
ApplicationMixer {
id: populatedMixer
width: 620
@@ -78,7 +97,15 @@ ShellRoot {
emptyRows: emptyMixer.rowCount,
emptyStatus: emptyMixer.statusText,
unavailableRows: unavailableMixer.rowCount,
unavailableStatus: unavailableMixer.statusText
unavailableStatus: unavailableMixer.statusText,
captureApplications: AudioDevices.captureApplications.length,
captureTypesValid: AudioDevices.captureApplications.every(application =>
application.nodes.every(node =>
(node.type & PwNodeType.AudioInStream)
=== PwNodeType.AudioInStream)),
captureRowVisible: captureProbe.visible,
ghostVisible: !!outputList.absent,
ghostLabel: outputList.absent ? String(outputList.absent.label) : ""
});
}
}
@@ -0,0 +1,129 @@
// Fixture-fed harness for the three sound services that shell out:
// SoundCards (pactl list cards / set-card-profile), SoundRouting (pactl
// move-sink-input) and SoundDefaults (pw-metadata -n default).
//
// Nothing here touches the real audio graph. The contracts that drive this file
// replace pactl and pw-metadata with recording stubs, so what is under test is
// the parsing and the argv -- not PipeWire, which has its own opinions and a
// different set on every machine.
//
// Application groups arrive as plain objects with the same shape AudioStreams.js
// produces, because that is what the services are handed at runtime and it is
// the part a refactor is most likely to break silently.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.services
ShellRoot {
id: root
// Two streams of one application, the way PipeWire numbers them: an
// `object.serial` that pactl accepts, distinct from the node id.
readonly property var twoStreamGroup: ({
key: "org.chromium.Chromium",
label: "Chromium",
icon: "chromium",
nodes: [
{ id: 61, properties: { "object.serial": "412" } },
{ id: 62, properties: { "object.serial": "418" } }
]
})
// A stream PipeWire never gave a serial. The node id is the fallback, and
// it has to be one: an application that cannot be moved is worse than one
// moved by a less stable handle.
readonly property var seriallessGroup: ({
key: "mpv",
label: "mpv",
icon: "mpv",
nodes: [{ id: 77, properties: {} }]
})
readonly property var emptyGroup: ({
key: "gone",
label: "Gone",
icon: "",
nodes: []
})
function groupNamed(name: string): var {
if (name === "twoStream")
return root.twoStreamGroup;
if (name === "serialless")
return root.seriallessGroup;
return root.emptyGroup;
}
IpcHandler {
target: "sound-services-test"
// ── SoundCards ──────────────────────────────────────────────────────
function cards(): string {
return JSON.stringify({
busy: SoundCards.busy,
lastError: SoundCards.lastError,
cards: SoundCards.cards
});
}
function refreshCards(): bool {
SoundCards.refresh();
return true;
}
function setProfile(card: string, profile: string): bool {
return SoundCards.setProfile(card, profile) !== false;
}
// ── SoundRouting ────────────────────────────────────────────────────
function routing(): string {
return JSON.stringify({
busy: SoundRouting.busy,
lastError: SoundRouting.lastError
});
}
function moveApplication(group: string, sink: string): bool {
return SoundRouting.moveApplication(root.groupNamed(group), sink) !== false;
}
function routeToDefault(group: string): bool {
return SoundRouting.routeToDefault(root.groupNamed(group)) !== false;
}
function currentSinkFor(group: string): string {
return String(SoundRouting.currentSinkFor(root.groupNamed(group)) ?? "");
}
// ── SoundDefaults ───────────────────────────────────────────────────
function defaults(): string {
return JSON.stringify({
sink: SoundDefaults.configuredSinkName,
source: SoundDefaults.configuredSourceName
});
}
function refreshDefaults(): bool {
SoundDefaults.refresh();
return true;
}
// What the ghost row would render, and what it would call the device.
// The name is all there is to go on: the node is gone, so there is no
// description to borrow.
function labelFor(name: string): string {
return SoundDefaults.label(name);
}
function absent(output: string): string {
const record = SoundDefaults.absent(output === "output");
return JSON.stringify(record === null ? { present: true } : {
present: false,
name: record.name,
label: record.label
});
}
}
}