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
+1 -1
View File
@@ -151,7 +151,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests
166 of them, under `tests/`. Run the lot, or a subset by pattern:
169 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh
panama test # everything
@@ -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
});
}
}
}
@@ -5,6 +5,6 @@
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Sound in Settings.
# @vicinae.keywords ["settings", "output volume", "input volume", "per-application volume", "event sounds"]
# @vicinae.keywords ["settings", "over-amplification", "volume-change blip", "output volume", "input volume", "per-application volume", "event sounds", "balance", "speaker test", "microphone test", "alert sound", "applications using the microphone", "move an application's audio"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page sound
+10 -1
View File
@@ -4,7 +4,7 @@
Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
after changing the schema; a contract fails when this copy is stale.
162 settings across 34 groups. 70 of them are applied to the compositor and confirmed by reading the value back.
164 settings across 35 groups. 70 of them are applied to the compositor and confirmed by reading the value back.
## accessibility
@@ -291,6 +291,15 @@ Found on **Applications Applications**.
|---|---|---|
| **Web search engine**<br>`webSearchUrl` | https://duckduckgo.com/?q= | Where the launcher's web search sends a query; the search text is appended |
## sound
Found on **Sound**.
| Setting | Default | What it does |
|---|---|---|
| **Over-amplification**<br>`overAmplification` | false | Lets the volume slider go to 150% — louder, at the cost of distortion on some hardware |
| **Volume-change blip**<br>`volumeChangeBlip` | true | A short click each time the volume keys move the output level |
## themes
Found on **Appearance**.
@@ -7,7 +7,7 @@ with Gabriel's go-ahead**, and failures get fixed then.
## The run
- `panama test` — the full suite (**166** contracts as of the phase 4 Shell
- `panama test` — the full suite (**169** contracts as of the phase 6 Sound
wave; the top-level README's count line is set to match and is itself
checked by `setup/readme-contract`).
@@ -249,3 +249,99 @@ re-checked against their files as those landed:
then `display-arrangement-contract`, and `displays-contract` last — it is the
only one that drives the physical display, and it refuses to start from a
scale that does not match what `monitors.lua` ships.
## Phase 6 (Sound) — append below
Spec: `2026-08-24-sound-redesign.md`. The Sound page became a complete PipeWire
surface: honest device list with badges and a ghost row, per-channel speaker
test, microphone test, per-app mic mute and per-app output routing, alert-sound
theme, over-amplification, and native device profiles in place of the GNOME
handoff.
**Nothing in this wave was run.** Three agents were editing the tree
concurrently and every harness here drives the live audio graph — the page
harness constructs the real Sound page against the session's own PipeWire, and
the services harness starts a shell. What *was* verified is listed as static
below: bash syntax on every contract, the JSON and pw-metadata fixtures parsed,
and each service's own parsing logic replayed in node or Python against the
landed source so the expected values in the assertions are the values the code
actually produces.
### New contracts (3)
| Contract | What it pins | Verified |
|---|---|---|
| `quickshell/sound-cards-contract` | `SoundCards`' parse of `pactl -f json list cards`: profiles keyed by name becoming an ordered list sorted by pactl priority (the fixture deliberately lists them out of order, so insertion order fails), a profile pactl marked unavailable kept and flagged rather than dropped, `device.description` as the card name, the port hint naming the connected port and reading exactly `No port connected` when none is, `set-card-profile` argv, an empty card or profile name starting no write, unparseable output and a failing read both degrading into `lastError` with an array still in `cards`, and recovery on the next refresh. Plus the static grep on the live `-f json` command, which the fixture seam means nothing else exercises. | **Statically verified**: `SoundCards.parse` and `portHintFor` replayed in Python against the fixture — profile order, availability flags, descriptions and both port hints match the assertions exactly. Every IPC call is **deferred**. |
| `quickshell/sound-routing-contract` | `SoundRouting`: every stream of a group moved, by `object.serial` and not by node id (both are plausible numbers in a log); a serial-less stream falling back to its node id; an empty group or unnamed sink running nothing; and `routeToDefault` releasing the pin through `pw-metadata -n default -d <node-id> target.object` **and** the legacy `target.node`, never through `move-sink-input` — moving a stream to the current default pins it there, which is the bug the button undoes. Plus the id asymmetry both ways, `busy`/`lastError` on refusal, recovery, and the spec-required comment above `routeToDefault`. | **Statically verified** against the landed service: the argv shapes, the serial fallback, the two metadata keys and the early returns all read directly off `SoundRouting.qml`. The runs are **deferred**. |
| `quickshell/sound-defaults-contract` | The one distinction the ghost row depends on: `default.configured.audio.sink` and not `default.audio.sink`. The fixture sets them to different values and writes the configured one first, so neither "last key wins" nor a machine whose configured device is present can make it pass by accident. Also: an unconfigured session reading as `""` rather than as the effective device, an empty store, a non-JSON value on an unrelated key not taking the read down with it, a failed read leaving nothing invented, recovery, the ghost record and its label (Bluetooth address, AirPlay hostname, USB product string, empty), and a static ban on reading `preferredDefaultAudioSink`, which is null in exactly the case the service exists for. | **Statically verified**: `SoundDefaults.parse` and `label` evaluated in node against all five fixtures — every expected string in the contract came from that run. The IPC half is **deferred**. |
### Updated contracts (4)
| Contract | What it now pins | Verified |
|---|---|---|
| `quickshell/sound-page-contract` | Rebuilt around the new page. Kept: the two `SoundDeviceList`s, the Dictation negatives and handoff, the balance and device-row pins, the Quick Settings sharing. Dropped: `openGnomePanel("sound")` and `label: "Device profiles"` — both now asserted **absent**, replaced by the native profile card (`title:`, `SoundCards.cards`, `setProfile(`, `refresh()`, `lastError`). Added: `captureApplications` filtered by `AudioInStream` and reaching `SoundCaptureRow` with a per-app mute, a tracked `PwObjectTracker` over capture nodes, the row hiding itself when nothing is listening; the ghost row asked of `SoundDefaults.absent(root.output)`, rendering after the `Repeater`, and non-interactive by a brace-scan proving every `TapHandler`/`HoverHandler` carries `!root.ghost`; the `device.api` badges; over-amplification gated at 1.5 on the page and on Quick Settings' **output** slider only, with the >100% region marked; the four `SoundRouting` calls in `ApplicationVolumeRow`. The no-shell-out ban is unchanged on the four core files and now extended over all eleven page components. | **The whole static half was run** against the landed tree and passes. The harness half is **deferred**. |
| `quickshell/application-volume-contract` | Grouping pins unchanged; new `clampVolume` case for the `max` parameter — `setVolume(group, 1.4, 1.5)` lands 1.4 and unmutes, 2.5 clamps to 1.5, **no** max clamps to 1 (the default must not quietly follow the preference), a negative clamps to 0, and a non-numeric value changes nothing and returns false. | **Statically verified**: the landed `AudioStreams.js` evaluated in node produced byte-identical output to the `jq` filter's expectations. The IPC run is **deferred**. |
| `quickshell/osd-helper-contract` | Over-amplification and the blip, both out of `settings.json`. Every run now gets its own `XDG_CONFIG_HOME`, because the helper would otherwise read Gabriel's real preferences and pass or fail on which switches he has on. Pins `-l 1.5` on volume up **and down** (wpctl clamps the result, so coming down from 130% would snap to 100% without it), `-l 1` on the microphone in the same run, `-l 1` for off / key absent / file absent / malformed JSON, the blip on up/down/toggle and not on brightness or microphone, silence with `volumeChangeBlip` false, and silence with no sound file — with the OSD still shown in every degraded case. | **Bash syntax only.** The helper is agent A's and had not landed when this was written; see the open item below. |
### Cross-agent shapes these contracts pin
Written from the spec's pinned API while agents A and B worked in parallel, then
re-checked against their files as those landed:
- `PANAMA_SOUND_CARDS_FIXTURE` and `PANAMA_SOUND_DEFAULTS_FIXTURE` are **file
paths** the service `cat`s in place of the live command, read once at
singleton construction. So a contract changes what the file *says* between
cases rather than where it points, and deletes it to make a read fail.
Confirmed against both landed services.
- `sound-services-harness.qml` is new and shared by all three service
contracts. Whichever contract is running sets an inert fixture for the two
services it is not testing, so nothing reaches the live daemon and every line
in a command log belongs to the service under test.
- `SoundDeviceList` asks `SoundDefaults.absent(output)` rather than comparing
configured names itself — the service owns both the comparison and the label.
The contract followed B's refactor to that shape.
- The ghost label is A's `SoundDefaults.label()`, so it reads
`Bluetooth device (AA:BB:CC:11:22:33)` **with** parentheses. `SoundDeviceList`
briefly had its own `tidyName()` producing the same string without them; the
harness and the assertion track the service's version.
### Docs updated in the same wave
- `services/SettingsSearch.qml` — the seven hand-written Sound entries from the
spec (Balance, Speaker test, Microphone test, Alert sound, Applications using
the microphone, Move an application's audio, Device profiles) on top of the
four that were already there, and `groupPages` gained `"sound": "sound"` so
the new schema group self-indexes.
- Top-level `README.md` — contract count 166 → 169, recounted with the same
`find` `setup/readme-contract` uses; that contract was run and passes.
### Still open before the run
- **`osd-helper-contract` is written against a `panama-osd` that had not
landed.** It assumes two things of agent A's helper: that the settings file
is resolved as `${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json` (the
`panama-idle` / `panama-lid` spelling, not `panama-palette`'s
`PANAMA_SETTINGS` override), and that the blip's sound file can be overridden
with `PANAMA_OSD_BLIP_SOUND` (matching the existing `PANAMA_OSD_*` seams in
the same script), which is the only way to exercise the missing-file branch
deterministically. If A spelled either differently, the env names in
`run_helper` are the only lines that need changing. Reconcile before running.
- The contract also now asserts `-l` on the **down** step, which the
pre-redesign helper did not pass. The reasoning is in the contract; if the
landed helper only limits the up step, that is a real bug at 150% and not a
contract to relax.
- `sound-page-contract`'s runtime half asserts `.captureApplications >= 0` and
`.captureRowVisible == (.captureApplications > 0)` — true on a machine where
nothing is recording, which is the ordinary case. Getting a positive capture
count under test would mean holding the microphone open from the contract;
the grouping itself is covered by `captureTypesValid` and by the static greps.
- No contract in this wave has had its harness started. Run order for the
sweep: `sound-defaults-contract`, `sound-cards-contract`,
`sound-routing-contract` (all three fixture-fed and cheap), then
`application-volume-contract`, then `osd-helper-contract`, and
`sound-page-contract` last — it is the only one that constructs the real page
against the session's own audio graph.
- Nothing in this wave plays a sound on purpose, but `sound-page-contract`
constructs `SoundPage`, whose microphone test and channel strip are one
IPC-less click away from `pw-play`. The harness exposes no method that
triggers either; keep it that way.
@@ -0,0 +1,172 @@
# Sound redesign — one page, whole story
Approved mock: `home-mocks/sound.html` (scratchpad, :8642). This spec is the implementation
contract; where the mock and this file disagree, this file wins. The page stays a single sectioned
scroll (no tabs). Everything is live PipeWire — nothing to apply or confirm.
## Goals
1. **Fix the Test/Use overflow**`SoundDeviceRow.qml`'s heading Column subtracts `useButton.width`
but not the Test button's width nor the inner Row's spacing, so output rows overflow the card by
`testWidth + 7` px. The rebuilt row layout must measure both buttons (no hardcoded 78/64 widths;
let implicitWidth speak).
2. **Honest device list** — badges and ghost rows so the list tells the whole story.
3. **Complete the page** — per-channel test, mic test, mic-using apps, per-app routing, alert
sounds Panama's own notifications obey, over-amplification, native device profiles.
Non-goals: alert-sound *volume* (no real channel; deliberately dropped), mono output, sample-rate
switching, EasyEffects/noise suppression, input-side balance.
## Constraints that stand
- `sound-page-contract` bans `Process|pactl|wpctl` in **AudioDevices / SoundDeviceList /
SoundDeviceRow / AudioBalance**. Anything that shells out lives in a sibling singleton service
(the SoundTest/SoundFeedback precedent). Update the contract only where the spec says so.
- Quick Settings keeps sharing `AudioDevices` (outputs/inputs/select).
- No continuously repainting animations. The input level meter uses `PwNodePeakMonitor` (native,
event-driven); it renders only while the Input section is on screen and the device is selected.
## Service API (pinned — UI programs against this)
**`AudioDevices.qml`** (native-only, extended):
- `captureApplications` — mic-using apps: `AudioStreams.group(captureStreams, PwNodeType.AudioInStream)`
where `captureStreams` filters `ready && audio && (type & PwNodeType.AudioInStream)`.
- `applicationVolume/Muted` + setters already exist and work for both groups (AudioStreams.js is
group-shape agnostic).
- Device badges are UI-side, native: `node.properties["device.api"]``"raop"` → AirPlay badge,
`"bluez5"` → Bluetooth badge. Subtitle rate/format from `node.properties["audio.rate"]` /
format keys when present; omit gracefully when absent.
**`SoundDefaults.qml`** (new singleton, shells out): the configured-vs-effective story.
- `configuredSinkName` / `configuredSourceName` — from `pw-metadata -n default 0` (or `pw-dump`
fallback; agent picks the stable one), refreshed on `Pipewire.defaultAudioSink/Source` changes
and on a page-open `refresh()`.
- The ghost row renders when the configured name maps to **no present node**; label from the
stored name (e.g. `bluez_output.74_15_F5_13_A4_28.1` → cleaned to the stored description when
pw-metadata carries one, else a tidied name). Ghost rows are non-interactive; badge text
"Returns when connected".
**`SoundCards.qml`** (new singleton, shells out): native device profiles.
- `cards: [{ name, description, activeProfile, profiles: [{ name, description, available }],
portHint }]` from `pactl -f json list cards` (portHint: best human line from port availability,
e.g. "Line out connected" / "No port connected").
- `setProfile(cardName, profileName)` → `pactl set-card-profile`; `busy`, `lastError`,
`refresh()`. Refresh on page open (the Displays `Brightness.refresh()` pattern).
**`SoundRouting.qml`** (new singleton, shells out): per-app output routing.
- `moveApplication(group, sinkName)` — `pactl move-sink-input <serial> <sink>` for every stream
in the group (stream serial from `node.properties["object.serial"]`, fallback `node.id`).
- `routeToDefault(group)` — return the app to following the system default (agent determines the
reliable mechanism: `pw-metadata` target clear, or move to the current default sink — document
which in a comment).
- `currentSinkFor(group)` — name of the sink the group's first stream is linked to, `""` when it
follows the default. Derived natively where possible (`Pipewire.linkGroups` is readable) —
shells out only if link groups prove insufficient.
- `busy`, `lastError`.
**`SoundTest.qml`** (extended, keeps its no-race separation):
- `playChannel(node, channelName)` — per-channel freedesktop samples
(`audio-channel-front-left.oga`, `-front-right`, `-front-center`, `-rear-left`, `-rear-right`,
`-side-left`, `-side-right` as they exist on disk), `pw-play --target <node.name>`.
- `channelsFor(node)` — ordered `[{ name, label }]` from `node.audio.channels` via
`PwAudioChannel`; stereo → Front Left / Front Right.
- `playingChannel` — for the lit chip.
- Mic test: `startMicTest(sourceNode, sinkNode)` → `pw-record` ~3 s to a file under
`$XDG_RUNTIME_DIR`, then `pw-play` it back; `micTestState`: `"idle" | "recording" | "playing"`;
`cancelMicTest()`. The temp file is overwritten each run, never accumulated.
**`SoundFeedback.qml`** (extended):
- Existing `eventSounds` / `inputFeedback` unchanged.
- `soundTheme` / `setSoundTheme(name)` — gsettings `org.gnome.desktop.sound theme-name`.
- `themes` — installed themes scanned from `/usr/share/sounds/*/index.theme` (name + directory).
- `previewAlert()` — plays the current theme's bell (`bell.oga`, fallback to freedesktop's) via
the SoundTest pw-play pattern, through the default sink.
**`Notifs.qml`**: when a notification popup is shown and `SoundFeedback.eventSounds` is true,
play the current theme's bell via a dedicated Process (`pw-play`), throttled so a burst of
notifications plays at most one sound per second. Low-urgency notifications stay silent.
**Schema** — new group `sound` (first audio keys in the schema; comments above entry braces):
- `overAmplification` bool, def false, label "Over-amplification", detail per mock ("Lets the
volume slider go to 150% — louder, at the cost of distortion on some hardware").
- `volumeChangeBlip` bool, def true, label "Volume-change blip", detail per mock.
`SettingsSearch.groupPages` gains `"sound": "sound"`.
**Over-amplification plumbing** (max is **1.5**, everywhere gated on the setting):
- `AudioStreams.setVolume(nodes, volume, max)` — clamp bound becomes a parameter (pure JS stays
Settings-free); callers pass `Settings.overAmplification ? 1.5 : 1`.
- Output volume sliders (SoundPage, quicksettings `AudioSlider` for the sink only — the mic
slider stays 01) get `max` 1.5 when enabled, with the >100% region visually marked.
- `scripts/panama-osd`: volume up/down uses `-l 1.5` when `overAmplification` is true in
`~/.config/panama/settings.json` (read with jq, tolerate a missing key/file).
**Volume blip**: `panama-osd` volume up/down/toggle plays
`/usr/share/sounds/freedesktop/stereo/audio-volume-change.oga` (pw-play, fire-and-forget,
skipped when the file is missing) when `volumeChangeBlip` is true in settings.json.
## Page layout (top to bottom)
`SoundPage.qml` rebuilt. Lede: "Live PipeWire — every change lands immediately, nothing to apply."
1. **Output card** — h2 with right-aligned current device name. Device rows (icon, name +
badges, subtitle, trailing control, radio): Test button on the selected output only; clicking
Test unfolds a **channel-chip strip** under that row (one chip per channel from
`SoundTest.channelsFor`, lit while `playingChannel` matches). AirPlay/Bluetooth badges. The
**ghost row** (configured-but-absent default) renders last, non-interactive, dimmed.
Below the list: Volume slider, Balance (existing AudioBalance), Over-amplification toggle.
2. **Input card** — device rows with the live level meter on the selected row (existing
`PwNodePeakMonitor`, widened to a proper meter with a warm→red tip). Input volume slider.
"Test your microphone" ActionRow → `startMicTest`, button label walks
Record & play back → Recording… → Playing back…. **Using the microphone** row: chips per
`captureApplications` group with a per-app mic mute; row hidden when empty.
3. **Applications card** — existing mixer rows extended with a per-app **output picker**
(System default + each present sink; `SoundRouting`). Keep the empty-state copy
"Applications playing sound will appear here" (contract-pinned).
4. **Alerts & feedback section** — Alerts card: Alert sound (theme dropdown + Preview),
Event sounds (detail now says "…and Panama's notification chime"), Volume-change blip,
Input feedback.
5. **Advanced section** — Device profiles card from `SoundCards` (name, portHint subtitle,
profile dropdown per card). Replaces the "Open panel" GNOME punt — `openGnomePanel("sound")`
leaves this page.
6. Error surfaces: each shelling service's `lastError` degrades its own card's subtitle, the
existing pattern.
## Search & docs
- Hand-written entries (page "sound"): Balance, Speaker test, Microphone test, Alert sound,
Applications using the microphone, Move an application's audio, Device profiles. Keep the four
existing ones. Schema group `sound` self-indexes via groupPages.
- README: no structural change needed beyond keeping the contract count line accurate.
- Settings docs and launcher commands regenerate after the schema lands (orchestrator does this
in the audit pass).
## Contracts (write, do NOT run)
- `sound-page-contract`: update pinned strings for the rebuilt page (the "exactly two
SoundDeviceList" pin may change if the list moves inline — pin whatever the final structure
is), keep the no-shell-out ban on the four core files, extend it to assert the new services
are the only Process users, keep the Dictation negatives, extend the harness assertions for
captureApplications and the ghost-row logic (static where possible).
- `application-volume-contract`: unchanged grouping pins; add clamp-parameter coverage
(`setVolume(nodes, v, 1.5)` clamps to 1.5, default max stays 1).
- New contracts for SoundCards/SoundRouting/SoundDefaults parsing (feed them canned
pactl/pw-metadata JSON via a test seam env var, the panama-brightness pattern).
- `osd-helper-contract`: extend for the blip and the 1.5 limit (settings.json fixtures).
- Everything lands in the test-backlog sweep list; nothing runs now.
## Agent ownership (parallel)
- **A — services & plumbing**: `services/AudioDevices.qml`, `services/AudioStreams.js`,
`services/SoundTest.qml`, `services/SoundFeedback.qml`, new `services/SoundDefaults.qml`,
`services/SoundCards.qml`, `services/SoundRouting.qml`, `services/Notifs.qml`,
`scripts/panama-osd`, `config/PreferenceSchema.qml` (sound group), services qmldir if one
exists for `qs.services`.
- **B — UI**: `modules/settings/SoundPage.qml`, `SoundDeviceList.qml`, `SoundDeviceRow.qml`,
`AudioBalance.qml`, `ApplicationMixer.qml`, `ApplicationVolumeRow.qml`, new components in
`modules/settings/` (+ their qmldir lines), `modules/quicksettings/AudioSlider.qml` (over-amp
max only).
- **C — periphery**: `services/SettingsSearch.qml`, `tests/quickshell/sound-page-contract`,
`application-volume-contract`, `osd-helper-contract`, new contracts + harness fixtures,
test-backlog spec, README count line if it changes.
B programs against the pinned API above; A must not change it without updating this spec.
@@ -77,6 +77,22 @@ mute_result="$(qs_for_harness ipc call application-volume-test mutateMute)"
jq -e '.changed == true and .muted == [true, true]' <<<"$mute_result" >/dev/null \
|| fail "mute mutation did not reach every stream: $mute_result"
# Over-amplification lives in Settings, and AudioStreams.js is pure JavaScript
# with no Settings import -- so the ceiling arrives as an argument. What must
# not happen is a default that quietly follows the preference: a caller that
# forgets to pass the max gets 100%, not 150%.
clamp_result="$(qs_for_harness ipc call application-volume-test clampVolume)"
jq -e '.overAmpChanged == true
and ((.overAmp[0] - 1.4) | fabs) < 0.000001
and (.overAmp | length) == 2 and (.overAmp[0] == .overAmp[1])
and .overAmpMuted == [false, false]
and .ceiling == [1.5, 1.5]
and .defaultMax == [1, 1]
and .floor == [0, 0]
and .nonNumericChanged == false
and .nonNumeric == [0.1, 0.1]' \
<<<"$clamp_result" >/dev/null || fail "volume clamp did not honour its max parameter: $clamp_result"
service_summary="$(qs_for_harness ipc call application-volume-test serviceSummary)"
jq -e '.count >= 0 and .validTypes == true' <<<"$service_summary" >/dev/null \
|| fail "live service exposed invalid playback groups: $service_summary"
+133
View File
@@ -94,6 +94,13 @@ elif [[ $1 == "status" ]]; then
fi
SH
cat >"$scratch/bin/pw-play" <<'SH'
#!/bin/bash
printf 'pw-play' >>"$OSD_TEST_LOG"
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
printf '\n' >>"$OSD_TEST_LOG"
SH
cat >"$scratch/bin/qs" <<'SH'
#!/bin/bash
printf 'qs' >>"$OSD_TEST_LOG"
@@ -104,10 +111,31 @@ SH
chmod +x "$scratch/bin/"*
# The blip and the over-amplification limit are both read out of Panama's own
# settings.json, so every run below gets its own config root. Without this the
# helper would read Gabriel's real preferences and the assertions would pass or
# fail depending on which switches he happens to have on.
blip_sound="$scratch/blip.oga"
: >"$blip_sound"
mkdir -p "$scratch/config-default"
# Write a settings.json for one run. No arguments means no file at all, which
# is what a fresh install looks like: the helper must fall back to the schema
# defaults rather than treating an absent file as an error.
settings_root() {
local name="$1" body="${2:-}" root="$scratch/config-$name"
rm -rf "$root"
mkdir -p "$root/panama"
[[ -n "$body" ]] && printf '%s\n' "$body" >"$root/panama/settings.json"
printf '%s' "$root"
}
run_helper() {
local runtime="${OSD_RUNTIME_DIR:-$scratch/runtime-default}"
mkdir -p "$runtime"
PATH="$scratch/bin:$PATH" OSD_TEST_LOG="$log" \
XDG_CONFIG_HOME="${OSD_CONFIG_HOME:-$scratch/config-default}" \
PANAMA_OSD_BLIP_SOUND="${OSD_BLIP_SOUND:-$blip_sound}" \
OSD_TEST_FAIL_QS="${OSD_TEST_FAIL_QS:-false}" \
PANAMA_OSD_STRICT="${PANAMA_OSD_STRICT:-false}" \
PANAMA_OSD_BRIGHTNESS_HELPER="$scratch/bin/panama-brightness" \
@@ -132,6 +160,31 @@ assert_line() {
}
}
# The blip is backgrounded and never waited on -- deliberately, so it cannot sit
# between the key press and the OSD. Which means the line may land after the
# helper has already exited, and asserting it needs a moment rather than an
# instant. Waiting here also keeps a slow blip from bleeding into the next
# case's freshly truncated log.
await_line() {
local expected="$1"
for _ in $(seq 1 60); do
grep -Fqx -- "$expected" "$log" && return 0
sleep 0.05
done
printf 'osd helper contract: missing call\n%s\nactual:\n' "$expected" >&2
cat "$log" >&2
exit 1
}
refute_line() {
local unexpected="$1" why="$2"
if grep -Fq -- "$unexpected" "$log"; then
printf 'osd helper contract: %s\nunexpected: %s\nactual:\n' "$why" "$unexpected" >&2
cat "$log" >&2
exit 1
fi
}
: >"$log"
run_helper volume up 6
assert_line 'wpctl <set-volume> <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>'
@@ -148,6 +201,86 @@ WPCTL_OUTPUT='Volume: 0.72 [MUTED]' run_helper microphone toggle
assert_line 'wpctl <set-mute> <@DEFAULT_AUDIO_SOURCE@> <toggle>'
assert_line 'qs <ipc> <call> <osd> <progress> <microphone-muted> <72> <100> <Muted>'
# ── Over-amplification: the ceiling is a setting, not a constant ─────────────
# `wpctl set-volume` clamps to 1.0 unless told otherwise, and it clamps the
# *result* -- so the limit has to be on the down step too. Without it, coming
# down from 130% would snap to 100% instead of stepping to 124%, which reads as
# the slider jumping on its own.
: >"$log"
OSD_CONFIG_HOME="$(settings_root overamp '{"overAmplification": true}')" \
run_helper volume up 6
assert_line 'wpctl <set-volume> <-l> <1.5> <@DEFAULT_AUDIO_SINK@> <6%+>'
: >"$log"
OSD_CONFIG_HOME="$(settings_root overamp '{"overAmplification": true}')" \
run_helper volume down 6
assert_line 'wpctl <set-volume> <-l> <1.5> <@DEFAULT_AUDIO_SINK@> <6%->'
# The microphone is not part of the bargain: no amount of gain past 100% makes
# a capture device better, and the Sound page's input slider stays 0-100 to
# match.
: >"$log"
OSD_CONFIG_HOME="$(settings_root overamp '{"overAmplification": true}')" \
run_helper microphone up 6
assert_line 'wpctl <set-volume> <-l> <1> <@DEFAULT_AUDIO_SOURCE@> <6%+>'
refute_line '<-l> <1.5> <@DEFAULT_AUDIO_SOURCE@>' \
'over-amplification leaked onto the microphone'
: >"$log"
OSD_CONFIG_HOME="$(settings_root plain '{"overAmplification": false}')" \
run_helper volume up 6
assert_line 'wpctl <set-volume> <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>'
# A settings file that predates the key, and a settings file that is not JSON
# at all, both mean "off" rather than "no volume keys work today".
: >"$log"
OSD_CONFIG_HOME="$(settings_root nokey '{"barBackdrop": true}')" run_helper volume up 6
assert_line 'wpctl <set-volume> <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>'
: >"$log"
OSD_CONFIG_HOME="$(settings_root broken '{not json')" run_helper volume up 6
assert_line 'wpctl <set-volume> <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>'
assert_line 'qs <ipc> <call> <osd> <progress> <volume> <58> <100> <58%>'
# ── The volume blip ─────────────────────────────────────────────────────────
# Default on, per the schema, so a settings.json that has never been written
# still clicks. Fire-and-forget: the blip must never gate the OSD.
: >"$log"
run_helper volume up 6
await_line "pw-play <$blip_sound>"
assert_line 'qs <ipc> <call> <osd> <progress> <volume> <58> <100> <58%>'
: >"$log"
run_helper volume down 6
await_line "pw-play <$blip_sound>"
: >"$log"
WPCTL_OUTPUT='Volume: 0.58' run_helper volume toggle
await_line "pw-play <$blip_sound>"
: >"$log"
OSD_CONFIG_HOME="$(settings_root silent '{"volumeChangeBlip": false}')" \
run_helper volume up 6
refute_line 'pw-play' 'the blip played with volumeChangeBlip off'
assert_line 'qs <ipc> <call> <osd> <progress> <volume> <58> <100> <58%>'
# Brightness and the microphone are not volume changes.
: >"$log"
run_helper brightness up 5
refute_line 'pw-play' 'a brightness step played the volume blip'
: >"$log"
WPCTL_OUTPUT='Volume: 0.72' run_helper microphone up 6
refute_line 'pw-play' 'a microphone step played the volume blip'
# A distribution without the freedesktop sound theme has no file to play. That
# is a silent desktop, not a broken volume key.
: >"$log"
OSD_BLIP_SOUND="$scratch/nothing-here.oga" run_helper volume up 6
refute_line 'pw-play' 'the blip ran against a file that does not exist'
assert_line 'wpctl <set-volume> <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>'
assert_line 'qs <ipc> <call> <osd> <progress> <volume> <58> <100> <58%>'
: >"$log"
run_helper brightness up 5
assert_line 'brightnessctl <-m> <-c> <backlight>'
+373
View File
@@ -0,0 +1,373 @@
#!/usr/bin/env bash
# Device profiles are the last thing the Sound page handed to GNOME Settings,
# and the reason it had to was that reading them means shelling out to pactl.
# SoundCards does that, so the page can show the profile dropdown itself.
#
# What is worth pinning is the parsing, because pactl's JSON is not the shape
# the UI wants and every mistake in the conversion is invisible until someone
# with a headset and a surround card opens the page:
#
# * profiles arrive keyed by name in an object, and come out as an ordered
# list, because a dropdown has an order and an object does not;
# * a profile pactl marked unavailable is kept and flagged, not dropped --
# "Headset" missing entirely is a bug report, "Headset (unavailable)" is an
# explanation;
# * the port hint says what is physically plugged in, which is the one thing
# the profile name never tells you;
# * pactl failing produces an error string, not an empty list that reads as
# "this machine has no sound card".
#
# Runs against canned pactl output. The real audio graph is never touched:
# pactl is replaced on PATH and through the service's own helper seam.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
service="$repo_dir/config/dot/quickshell/services/SoundCards.qml"
harness="$repo_dir/config/dot/quickshell/sound-services-harness.qml"
fixture="$(mktemp -d /tmp/panama-sound-cards.XXXXXX)"
state_home="$fixture/state"
shell_log="$fixture/quickshell.log"
harness_pid=""
fail() {
printf 'sound cards contract: %s\n' "$1" >&2
[[ -s "$shell_log" ]] && sed -n '1,80p' "$shell_log" >&2
exit 1
}
[[ -f "$service" ]] || fail 'SoundCards.qml is missing'
[[ -f "$harness" ]] || fail 'sound-services-harness.qml is missing'
mkdir -p "$fixture/bin" "$state_home"
# Two cards, chosen for the two things that go wrong. The built-in card has a
# connected line out and an available analog profile. The headset has no port
# connected at all and a profile pactl reports as unavailable, which is what a
# Bluetooth device looks like between "paired" and "actually here".
cat >"$fixture/cards.good.json" <<'JSON'
[
{
"index": 47,
"name": "alsa_card.pci-0000_00_1f.3",
"driver": "module-alsa-card.c",
"properties": {
"device.description": "Built-in Audio",
"device.api": "alsa"
},
"profiles": {
"off": {
"description": "Off",
"sinks": 0,
"sources": 0,
"priority": 0,
"available": true
},
"output:hdmi-stereo": {
"description": "Digital Stereo (HDMI) Output",
"sinks": 1,
"sources": 0,
"priority": 5900,
"available": false
},
"output:analog-stereo+input:analog-stereo": {
"description": "Analog Stereo Duplex",
"sinks": 1,
"sources": 1,
"priority": 6565,
"available": true
}
},
"active_profile": "output:analog-stereo+input:analog-stereo",
"ports": {
"analog-output-lineout": {
"description": "Line Out",
"type": "Line",
"priority": 9900,
"availability_group": "Legacy 1",
"availability": "available"
},
"analog-output-headphones": {
"description": "Headphones",
"type": "Headphones",
"priority": 9000,
"availability_group": "Legacy 2",
"availability": "not available"
}
}
},
{
"index": 51,
"name": "bluez_card.74_15_F5_13_A4_28",
"driver": "module-bluez5-device.c",
"properties": {
"device.description": "WH-1000XM4",
"device.api": "bluez5"
},
"profiles": {
"a2dp-sink": {
"description": "High Fidelity Playback (A2DP Sink)",
"sinks": 1,
"sources": 0,
"priority": 40,
"available": true
},
"headset-head-unit": {
"description": "Headset Head Unit (HSP/HFP)",
"sinks": 1,
"sources": 1,
"priority": 30,
"available": false
}
},
"active_profile": "a2dp-sink",
"ports": {
"bluez-output": {
"description": "Headphone",
"type": "Headphones",
"priority": 0,
"availability_group": "",
"availability": "not available"
}
}
}
]
JSON
# The read side never runs pactl: SoundCards' own seam,
# PANAMA_SOUND_CARDS_FIXTURE, points it at a file it `cat`s instead. So this
# stub exists for the *write* side -- `pactl set-card-profile` -- and to prove
# the read side did not quietly fall back to the live daemon.
cat >"$fixture/bin/pactl" <<'STUB'
#!/usr/bin/env bash
printf 'pactl' >>"$PANAMA_SOUND_PACTL_LOG"
printf ' <%s>' "$@" >>"$PANAMA_SOUND_PACTL_LOG"
printf '\n' >>"$PANAMA_SOUND_PACTL_LOG"
for arg in "$@"; do
[[ "$arg" == "cards" ]] && { printf '[]\n'; exit 0; }
done
exit 0
STUB
chmod +x "$fixture/bin/pactl"
# SoundDefaults shares this harness and reads on construction. Give it a
# fixture of its own so it cannot reach the session's real metadata.
: >"$fixture/defaults"
export PANAMA_SOUND_PACTL_LOG="$fixture/pactl.log"
export PANAMA_SOUND_CARDS_FIXTURE="$fixture/cards.json"
export PANAMA_SOUND_DEFAULTS_FIXTURE="$fixture/defaults"
: >"$PANAMA_SOUND_PACTL_LOG"
# The seam is a path read once at construction, so a case changes what the
# file says rather than where it points. `absent` deletes it, which is how a
# read that fails looks from the service's side.
cards_fixture() {
case "$1" in
good) cp "$fixture/cards.good.json" "$PANAMA_SOUND_CARDS_FIXTURE" ;;
malformed) printf 'Failure: Module initialization failed\n' >"$PANAMA_SOUND_CARDS_FIXTURE" ;;
absent) rm -f "$PANAMA_SOUND_CARDS_FIXTURE" ;;
esac
}
instances_for_harness() {
qs list --all 2>/dev/null | awk -v expected="$harness" '
/^Instance / { pid = "" }
/^[[:space:]]*Process ID:/ { pid = $3 }
/^[[:space:]]*Config path:/ {
path = $0
sub(/^[[:space:]]*Config path: /, "", path)
if (path == expected && pid ~ /^[0-9]+$/) print pid
}
'
}
cleanup() {
if [[ "$harness_pid" =~ ^[0-9]+$ ]] && kill -0 "$harness_pid" 2>/dev/null; then
kill "$harness_pid" 2>/dev/null || true
for _ in $(seq 1 40); do
kill -0 "$harness_pid" 2>/dev/null || break
sleep 0.05
done
fi
rm -rf "$fixture"
}
trap cleanup EXIT
run() {
PATH="$fixture/bin:$PATH" \
XDG_STATE_HOME="$state_home" \
PANAMA_SOUND_PACTL_LOG="$PANAMA_SOUND_PACTL_LOG" \
PANAMA_SOUND_CARDS_FIXTURE="$PANAMA_SOUND_CARDS_FIXTURE" \
PANAMA_SOUND_DEFAULTS_FIXTURE="$PANAMA_SOUND_DEFAULTS_FIXTURE" \
qs -p "$harness" "$@"
}
ipc() {
if [[ "$harness_pid" =~ ^[0-9]+$ ]]; then
run ipc --pid "$harness_pid" "$@"
else
run ipc "$@"
fi
}
run --daemonize >"$shell_log" 2>&1 || fail 'sound services harness did not launch'
for _ in $(seq 1 60); do
harness_pid="$(instances_for_harness | head -1)"
if [[ "$harness_pid" =~ ^[0-9]+$ ]] \
&& ipc show 2>/dev/null | rg -q '^target sound-services-test$'; then
break
fi
sleep 0.1
done
[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'sound services harness process did not start'
ipc show 2>/dev/null | rg -q '^target sound-services-test$' \
|| fail 'sound-services-test IPC target did not register'
# ── The good parse ───────────────────────────────────────────────────────────
cards_fixture good
ipc call sound-services-test refreshCards >/dev/null
state=""
for _ in $(seq 1 60); do
state="$(ipc call sound-services-test cards)"
[[ "$(jq -r '.cards | length' <<<"$state")" == "2" ]] && break
sleep 0.1
done
jq -e '.cards | length == 2' >/dev/null <<<"$state" \
|| fail "canned pactl output did not become two cards: $state"
jq -e '.lastError == ""' >/dev/null <<<"$state" \
|| fail "a successful listing carried an error: $state"
# The fixture is the whole read. A service that fell back to the live daemon
# when handed one would pass every assertion below on the developer's machine
# and none of them on anyone else's.
if grep -Fq 'list' "$PANAMA_SOUND_PACTL_LOG"; then
fail "the fixture was ignored and the live daemon was read: $(cat "$PANAMA_SOUND_PACTL_LOG")"
fi
# Which leaves the live command itself unexercised, so pin it where it is
# written. `-f json` is the load-bearing half: without it pactl prints a
# human-readable block that JSON.parse rejects, and every card disappears.
rg -Fq '"pactl", "-f", "json", "list", "cards"' "$service" \
|| fail 'the live card listing is not the JSON one'
jq -e '.cards[0].name == "alsa_card.pci-0000_00_1f.3"
and .cards[0].description == "Built-in Audio"
and .cards[0].activeProfile == "output:analog-stereo+input:analog-stereo"' \
>/dev/null <<<"$state" || fail "the built-in card lost its identity: $state"
# Profiles become an ordered list. An object has no order, and a dropdown does.
jq -e '.cards[0].profiles | type == "array"' >/dev/null <<<"$state" \
|| fail "profiles are still keyed by name, so the dropdown has no order: $state"
jq -e '(.cards[0].profiles | map(.name)) | index("output:analog-stereo+input:analog-stereo") != null
and (.cards[0].profiles | map(.name) | index("output:hdmi-stereo")) != null' \
>/dev/null <<<"$state" || fail "a profile pactl reported went missing: $state"
jq -e '(.cards[0].profiles[] | select(.name == "output:analog-stereo+input:analog-stereo") | .description)
== "Analog Stereo Duplex"' >/dev/null <<<"$state" \
|| fail "profiles are labelled by their internal name rather than their description: $state"
# The order is pactl's priority, descending -- the order PulseAudio and GNOME
# both present, and the one that puts "Off" at the bottom where nobody clicks
# it by accident. The fixture's priorities (6565, 5900, 0) are deliberately not
# the order the JSON lists them in, so a service that kept insertion order
# fails here.
jq -e '(.cards[0].profiles | map(.name))
== ["output:analog-stereo+input:analog-stereo", "output:hdmi-stereo", "off"]' \
>/dev/null <<<"$state" || fail "profiles are not ordered by priority: $state"
# Unavailable profiles are kept and flagged. Dropping them is how a card ends
# up silently missing the mode someone is looking for.
jq -e '(.cards[0].profiles[] | select(.name == "output:hdmi-stereo") | .available) == false
and (.cards[0].profiles[] | select(.name == "output:analog-stereo+input:analog-stereo") | .available) == true' \
>/dev/null <<<"$state" || fail "profile availability was not carried through: $state"
# The port hint says what is plugged in. The spec's own example is "Line out
# connected"; this pins the two halves rather than the exact casing, so a
# sentence tweak does not read as a regression -- but a hint that names the
# wrong port, or none, does.
hint="$(jq -r '.cards[0].portHint' <<<"$state")"
[[ "$(tr '[:upper:]' '[:lower:]' <<<"$hint")" == *"line out"* ]] \
|| fail "the built-in card's port hint does not name its connected port: $hint"
[[ "$(tr '[:upper:]' '[:lower:]' <<<"$hint")" == *"connected"* ]] \
|| fail "the port hint does not say the port is connected: $hint"
# Nothing plugged in says so, in the spec's words.
[[ "$(jq -r '.cards[1].portHint' <<<"$state")" == "No port connected" ]] \
|| fail "a card with no available port did not say so: $(jq -r '.cards[1].portHint' <<<"$state")"
jq -e '.cards[1].name == "bluez_card.74_15_F5_13_A4_28"
and .cards[1].description == "WH-1000XM4"
and .cards[1].activeProfile == "a2dp-sink"
and ((.cards[1].profiles[] | select(.name == "headset-head-unit") | .available) == false)' \
>/dev/null <<<"$state" || fail "the Bluetooth card did not survive the parse: $state"
# ── Switching a profile ──────────────────────────────────────────────────────
: >"$PANAMA_SOUND_PACTL_LOG"
ipc call sound-services-test setProfile \
alsa_card.pci-0000_00_1f.3 output:hdmi-stereo >/dev/null
for _ in $(seq 1 60); do
grep -Fq 'set-card-profile' "$PANAMA_SOUND_PACTL_LOG" && break
sleep 0.1
done
grep -Fq 'pactl <set-card-profile> <alsa_card.pci-0000_00_1f.3> <output:hdmi-stereo>' \
"$PANAMA_SOUND_PACTL_LOG" \
|| fail "profile switch did not reach pactl: $(cat "$PANAMA_SOUND_PACTL_LOG")"
# A card or profile nobody named is not a reason to run pactl with an empty
# argument and let it decide.
: >"$PANAMA_SOUND_PACTL_LOG"
ipc call sound-services-test setProfile "" output:hdmi-stereo >/dev/null || true
ipc call sound-services-test setProfile alsa_card.pci-0000_00_1f.3 "" >/dev/null || true
sleep 0.3
if grep -Fq 'set-card-profile' "$PANAMA_SOUND_PACTL_LOG"; then
fail "an empty card or profile name still started a pactl write: $(cat "$PANAMA_SOUND_PACTL_LOG")"
fi
# ── pactl answering with something that is not JSON ──────────────────────────
cards_fixture malformed
ipc call sound-services-test refreshCards >/dev/null
for _ in $(seq 1 60); do
state="$(ipc call sound-services-test cards)"
[[ -n "$(jq -r '.lastError' <<<"$state")" ]] && break
sleep 0.1
done
jq -e '.lastError != "" and (.cards | type == "array")' >/dev/null <<<"$state" \
|| fail "unparseable pactl output did not degrade into an error: $state"
# ── pactl not answering at all ───────────────────────────────────────────────
cards_fixture absent
ipc call sound-services-test refreshCards >/dev/null
for _ in $(seq 1 60); do
state="$(ipc call sound-services-test cards)"
[[ -n "$(jq -r '.lastError' <<<"$state")" ]] && break
sleep 0.1
done
jq -e '.lastError != "" and .busy == false' >/dev/null <<<"$state" \
|| fail "a failing pactl left the service busy or silent: $state"
# ── Recovery ─────────────────────────────────────────────────────────────────
# An error is a state, not a terminal one. The card comes back on the next
# refresh, and the error goes away with it.
cards_fixture good
ipc call sound-services-test refreshCards >/dev/null
for _ in $(seq 1 60); do
state="$(ipc call sound-services-test cards)"
jq -e '.lastError == "" and (.cards | length) == 2' >/dev/null <<<"$state" && break
sleep 0.1
done
jq -e '.lastError == "" and (.cards | length) == 2' >/dev/null <<<"$state" \
|| fail "the service never recovered from a failed listing: $state"
if rg -n 'ReferenceError|TypeError|Binding loop|Unable to assign|Cannot assign' "$shell_log"; then
fail 'sound services harness emitted a QML runtime warning'
fi
# The Sound page is a PipeWire surface. SoundCards is allowed to shell out --
# it is the sibling singleton that exists so the page does not have to -- but
# it must be the one place that speaks pactl about cards.
rg -Fq 'pragma Singleton' "$service" || fail 'SoundCards is not a singleton'
trap - EXIT
cleanup
printf 'sound cards contract: PASS\n'
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env bash
# The device list used to show only what is present, which meant that unplugging
# a headset made the row that owned the sound vanish and left no trace of why
# the laptop speakers had taken over. PipeWire remembers the choice; the page
# did not show it.
#
# SoundDefaults is what makes the ghost row possible. Its whole job is the
# difference between two keys that look the same in a log:
#
# default.audio.sink what is playing right now
# default.configured.audio.sink what was chosen, present or not
#
# Reading the first one is the bug, and it is a quiet one: it always names a
# device that exists, so the ghost row never appears and the feature does
# nothing on every machine where the configured device happens to be plugged
# in -- which is most machines, most of the time. This pins the second.
#
# It also pins the label, because an absent device has no description to borrow
# and the stored name is all there is to work with.
#
# Runs against canned metadata through the service's own fixture seam. The
# session's real defaults are never read and never written.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
service="$repo_dir/config/dot/quickshell/services/SoundDefaults.qml"
harness="$repo_dir/config/dot/quickshell/sound-services-harness.qml"
fixture="$(mktemp -d /tmp/panama-sound-defaults.XXXXXX)"
state_home="$fixture/state"
shell_log="$fixture/quickshell.log"
harness_pid=""
# A Bluetooth address no adapter in this house has ever seen, so the "not
# present" assertions cannot be flipped by what happens to be paired during the
# sweep. The effective sink is a plausible built-in card, and the point of the
# fixture is that the two disagree.
configured_sink="bluez_output.AA_BB_CC_11_22_33.1"
effective_sink="alsa_output.pci-0000_00_1f.3.analog-stereo"
configured_source="alsa_input.usb-Blue_Microphones_Yeti-00.analog-stereo"
fail() {
printf 'sound defaults contract: %s\n' "$1" >&2
[[ -s "$shell_log" ]] && sed -n '1,80p' "$shell_log" >&2
exit 1
}
[[ -f "$service" ]] || fail 'SoundDefaults.qml is missing'
[[ -f "$harness" ]] || fail 'sound-services-harness.qml is missing'
mkdir -p "$fixture/bin" "$state_home"
# The headset is the configured sink and is not here; the built-in card is what
# is actually playing. A service that read the effective key would report the
# built-in card and be wrong in the one case this exists for. The configured
# line is written *first* so that "last key wins" is not what makes it pass.
cat >"$fixture/metadata-absent" <<JSON
Found "default" metadata 30
update: id:0 key:'default.configured.audio.sink' value:'{"name":"$configured_sink"}' type:'Spa:String:JSON'
update: id:0 key:'default.audio.sink' value:'{"name":"$effective_sink"}' type:'Spa:String:JSON'
update: id:0 key:'default.configured.audio.source' value:'{"name":"$configured_source"}' type:'Spa:String:JSON'
update: id:0 key:'default.audio.source' value:'{"name":"$configured_source"}' type:'Spa:String:JSON'
JSON
# Nothing was ever configured: PipeWire is picking by priority. There is no
# ghost to draw, and an empty string is how that has to read -- not the name of
# whatever happens to be playing, which would render a ghost row for a device
# that is present, sitting underneath its own real row.
cat >"$fixture/metadata-unconfigured" <<JSON
Found "default" metadata 30
update: id:0 key:'default.audio.sink' value:'{"name":"$effective_sink"}' type:'Spa:String:JSON'
update: id:0 key:'default.audio.source' value:'{"name":"$configured_source"}' type:'Spa:String:JSON'
JSON
cat >"$fixture/metadata-empty" <<'JSON'
Found "default" metadata 30
JSON
# pw-metadata prints keys it does not own alongside the ones it does, and some
# of them are bare strings rather than JSON. One of those must not take the
# whole read down with it.
cat >"$fixture/metadata-noise" <<JSON
Found "default" metadata 30
update: id:0 key:'log.level' value:'2' type:'Spa:String:JSON'
update: id:0 key:'clock.force-quantum' value:'not json at all' type:'Spa:String:JSON'
update: id:0 key:'default.configured.audio.sink' value:'{"name":"$configured_sink"}' type:'Spa:String:JSON'
JSON
# SoundCards shares this harness. An inert fixture keeps it off the live daemon.
printf '[]\n' >"$fixture/cards.json"
# Nothing here should reach pipewire, but if the fixture seam ever regressed,
# these stubs are what stands between the contract and the session's real
# defaults. They answer nothing, which fails the assertions loudly rather than
# passing them against real hardware.
for tool in pw-metadata pw-dump pactl wpctl; do
printf '%s\n' '#!/usr/bin/env bash' 'exit 1' >"$fixture/bin/$tool"
chmod +x "$fixture/bin/$tool"
done
export PANAMA_SOUND_DEFAULTS_FIXTURE="$fixture/defaults"
export PANAMA_SOUND_CARDS_FIXTURE="$fixture/cards.json"
# The seam is a path read once at construction, so a case changes what the file
# says rather than where it points. `absent` deletes it, which is what a read
# that fails looks like from the service's side.
defaults_fixture() {
if [[ "$1" == "failing" ]]; then
rm -f "$PANAMA_SOUND_DEFAULTS_FIXTURE"
else
cp "$fixture/metadata-$1" "$PANAMA_SOUND_DEFAULTS_FIXTURE"
fi
}
defaults_fixture absent
instances_for_harness() {
qs list --all 2>/dev/null | awk -v expected="$harness" '
/^Instance / { pid = "" }
/^[[:space:]]*Process ID:/ { pid = $3 }
/^[[:space:]]*Config path:/ {
path = $0
sub(/^[[:space:]]*Config path: /, "", path)
if (path == expected && pid ~ /^[0-9]+$/) print pid
}
'
}
cleanup() {
if [[ "$harness_pid" =~ ^[0-9]+$ ]] && kill -0 "$harness_pid" 2>/dev/null; then
kill "$harness_pid" 2>/dev/null || true
for _ in $(seq 1 40); do
kill -0 "$harness_pid" 2>/dev/null || break
sleep 0.05
done
fi
rm -rf "$fixture"
}
trap cleanup EXIT
run() {
PATH="$fixture/bin:$PATH" \
XDG_STATE_HOME="$state_home" \
PANAMA_SOUND_DEFAULTS_FIXTURE="$PANAMA_SOUND_DEFAULTS_FIXTURE" \
PANAMA_SOUND_CARDS_FIXTURE="$PANAMA_SOUND_CARDS_FIXTURE" \
qs -p "$harness" "$@"
}
ipc() {
if [[ "$harness_pid" =~ ^[0-9]+$ ]]; then
run ipc --pid "$harness_pid" "$@"
else
run ipc "$@"
fi
}
# Refresh, then wait for the parsed value to settle into the shape the filter
# describes. Everything passed here goes to jq, so a case can bring its own
# `--arg`.
await_defaults() {
local state=""
ipc call sound-services-test refreshDefaults >/dev/null
for _ in $(seq 1 60); do
state="$(ipc call sound-services-test defaults)"
jq -e "$@" >/dev/null <<<"$state" && { printf '%s' "$state"; return 0; }
sleep 0.1
done
printf '%s' "$state"
return 1
}
run --daemonize >"$shell_log" 2>&1 || fail 'sound services harness did not launch'
for _ in $(seq 1 60); do
harness_pid="$(instances_for_harness | head -1)"
if [[ "$harness_pid" =~ ^[0-9]+$ ]] \
&& ipc show 2>/dev/null | rg -q '^target sound-services-test$'; then
break
fi
sleep 0.1
done
[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'sound services harness process did not start'
# ── Configured, and not here ─────────────────────────────────────────────────
state="$(await_defaults --arg sink "$configured_sink" '.sink == $sink')" \
|| fail "the configured sink was not read from the configured key: $state"
[[ "$(jq -r .sink <<<"$state")" != "$effective_sink" ]] \
|| fail 'the effective sink was reported as the configured one, so no ghost row can ever appear'
[[ "$(jq -r .source <<<"$state")" == "$configured_source" ]] \
|| fail "the configured source was not read: $state"
# ── The ghost row ────────────────────────────────────────────────────────────
# Configured, absent from the graph, and labelled from the stored name -- which
# is all there is, because the node that carried the description is gone.
ghost="$(ipc call sound-services-test absent output)"
jq -e --arg sink "$configured_sink" '.present == false and .name == $sink' \
>/dev/null <<<"$ghost" || fail "a configured device that is not present did not become a ghost row: $ghost"
[[ "$(jq -r .label <<<"$ghost")" == "Bluetooth device (AA:BB:CC:11:22:33)" ]] \
|| fail "the ghost row's label is the raw node name: $(jq -r .label <<<"$ghost")"
# The label is the only part of a ghost row anyone reads, and the names it has
# to work with are structured differently per transport.
[[ "$(ipc call sound-services-test labelFor raop_sink.Living-Room.local.192.168.1.162.7000)" \
== "Living Room" ]] \
|| fail 'an AirPlay speaker is not named by its mDNS hostname'
[[ "$(ipc call sound-services-test labelFor alsa_output.usb-Generic_USB_Audio-00.analog-stereo)" \
== "Generic USB Audio" ]] \
|| fail 'a USB device is not named by its product string'
[[ "$(ipc call sound-services-test labelFor "")" == "" ]] \
|| fail 'an empty name produced a label out of nothing'
# ── Keys that are not ours ───────────────────────────────────────────────────
# pw-metadata prints the whole store, and some of it is not JSON. One bad line
# must not take the read down with it.
defaults_fixture noise
state="$(await_defaults --arg sink "$configured_sink" '.sink == $sink')" \
|| fail "a non-JSON value on an unrelated key discarded the whole read: $state"
# ── Nothing configured ───────────────────────────────────────────────────────
defaults_fixture unconfigured
state="$(await_defaults '.sink == ""')" \
|| fail "an unconfigured default did not read as empty: $state"
[[ "$(jq -r .source <<<"$state")" == "" ]] \
|| fail "an unconfigured source did not read as empty: $state"
jq -e '.present == true' >/dev/null <<<"$(ipc call sound-services-test absent output)" \
|| fail 'a session with no configured default still drew a ghost row'
# ── An empty metadata store ──────────────────────────────────────────────────
defaults_fixture empty
state="$(await_defaults '.sink == "" and .source == ""')" \
|| fail "an empty metadata store did not read as empty: $state"
# ── The read failing ─────────────────────────────────────────────────────────
# A session where this cannot be read is a session with no ghost rows, which is
# the pre-redesign behaviour and perfectly usable. It is not a session where the
# Sound page reports a device nobody configured.
defaults_fixture failing
state="$(await_defaults '.sink == "" and .source == ""')" \
|| fail "a failed read left a stale or invented configured device: $state"
# ── Recovery ─────────────────────────────────────────────────────────────────
defaults_fixture absent
state="$(await_defaults --arg sink "$configured_sink" '.sink == $sink')" \
|| fail "the service never recovered after a failed read: $state"
if rg -n 'ReferenceError|TypeError|Binding loop|Unable to assign|Cannot assign' "$shell_log"; then
fail 'sound services harness emitted a QML runtime warning'
fi
# ── Static ───────────────────────────────────────────────────────────────────
rg -Fq 'pragma Singleton' "$service" || fail 'SoundDefaults is not a singleton'
# The two greps that say which keys are being read. A regression here is
# invisible at runtime on any machine whose configured device is plugged in.
rg -Fq 'default.configured.audio.sink' "$service" \
|| fail 'SoundDefaults does not name the configured sink key'
rg -Fq 'default.configured.audio.source' "$service" \
|| fail 'SoundDefaults does not name the configured source key'
# The live command, which the fixture seam means nothing above exercises.
rg -Fq '"pw-metadata", "-n", "default", "0"' "$service" \
|| fail 'the live read is not pw-metadata against the default metadata store'
# `Pipewire.preferredDefaultAudioSink` is the same configured value typed as a
# node pointer, so it reads null in exactly the case this service exists for.
# Binding the ghost row to it would make the ghost row impossible. The file's
# own comment is allowed to say so; the code is not allowed to do it.
python3 - "$service" <<'PY' || fail 'the configured device was read as a node pointer, which is null precisely when the device is absent'
import sys
for line in open(sys.argv[1], encoding="utf-8"):
if line.strip().startswith("//"):
continue
if "preferredDefaultAudio" in line:
raise SystemExit(1)
PY
trap - EXIT
cleanup
printf 'sound defaults contract: PASS\n'
+206 -17
View File
@@ -4,26 +4,46 @@
# another settings app. This contract keeps the real device plumbing shared
# with Quick Settings and verifies the controls that must remain available.
#
# It also owns the line between Sound and Dictation. Dictation used to be a
# It owns two lines that the page keeps wanting to cross.
#
# The first is the line between Sound and Dictation. Dictation used to be a
# card on this page, because it listens through the input device chosen here.
# It is an input method, so it now sits under Input with the keyboard -- and
# the thing that made the old arrangement legible, that the microphone and the
# dictation setup were visibly the same subject, has to survive the move as an
# explicit handoff rather than as a second device picker.
#
# The second is the line between the page and the shell. Anything that speaks
# pactl, wpctl or pw-metadata races the PipeWire objects AudioDevices already
# holds, so it lives in a sibling singleton -- SoundTest, SoundFeedback, and
# now SoundCards, SoundRouting and SoundDefaults. The device list, the rows,
# the balance control and the service that owns discovery must stay native, and
# no component on the page may grow a Process of its own.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
sound_page="$repo_dir/config/dot/quickshell/modules/settings/SoundPage.qml"
dictation_page="$repo_dir/config/dot/quickshell/modules/settings/DictationPage.qml"
device_list="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceList.qml"
device_row="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceRow.qml"
application_mixer="$repo_dir/config/dot/quickshell/modules/settings/ApplicationMixer.qml"
application_row="$repo_dir/config/dot/quickshell/modules/settings/ApplicationVolumeRow.qml"
balance="$repo_dir/config/dot/quickshell/modules/settings/AudioBalance.qml"
audio_devices="$repo_dir/config/dot/quickshell/services/AudioDevices.qml"
sound_feedback="$repo_dir/config/dot/quickshell/services/SoundFeedback.qml"
settings="$repo_dir/config/dot/quickshell/modules/settings"
services="$repo_dir/config/dot/quickshell/services"
sound_page="$settings/SoundPage.qml"
dictation_page="$settings/DictationPage.qml"
device_list="$settings/SoundDeviceList.qml"
device_row="$settings/SoundDeviceRow.qml"
application_mixer="$settings/ApplicationMixer.qml"
application_row="$settings/ApplicationVolumeRow.qml"
balance="$settings/AudioBalance.qml"
badge="$settings/SoundBadge.qml"
capture_row="$settings/SoundCaptureRow.qml"
channel_strip="$settings/SoundChannelStrip.qml"
theme_row="$settings/SoundThemeRow.qml"
volume_row="$settings/SoundVolumeRow.qml"
audio_devices="$services/AudioDevices.qml"
sound_feedback="$services/SoundFeedback.qml"
sound_cards="$services/SoundCards.qml"
sound_routing="$services/SoundRouting.qml"
sound_defaults="$services/SoundDefaults.qml"
quick_devices="$repo_dir/config/dot/quickshell/modules/quicksettings/AudioDeviceList.qml"
quick_slider="$repo_dir/config/dot/quickshell/modules/quicksettings/AudioSlider.qml"
harness="$repo_dir/config/dot/quickshell/sound-page-harness.qml"
config_home="$(mktemp -d /tmp/panama-sound-config.XXXXXX)"
state_home="$(mktemp -d /tmp/panama-sound-state.XXXXXX)"
@@ -34,8 +54,25 @@ fail() {
exit 1
}
# The harness reads the two shelling services through their fixture seams, so
# the page's structure is measured against canned state rather than against
# whatever hardware happens to be plugged in. The configured sink below is a
# Bluetooth address nothing in this session can be, which is what makes the
# ghost-row assertions deterministic.
ghost_sink="bluez_output.AA_BB_CC_11_22_33.1"
cards_fixture="$state_home/cards.json"
defaults_fixture="$state_home/defaults"
printf '[]\n' >"$cards_fixture"
cat >"$defaults_fixture" <<JSON
Found "default" metadata 30
update: id:0 key:'default.configured.audio.sink' value:'{"name":"$ghost_sink"}' type:'Spa:String:JSON'
JSON
qs_for_harness() {
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" qs -p "$harness" "$@"
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \
PANAMA_SOUND_CARDS_FIXTURE="$cards_fixture" \
PANAMA_SOUND_DEFAULTS_FIXTURE="$defaults_fixture" \
qs -p "$harness" "$@"
}
cleanup() {
@@ -44,7 +81,14 @@ cleanup() {
}
trap cleanup EXIT
for file in "$sound_page" "$dictation_page" "$device_list" "$device_row" "$application_mixer" "$application_row" "$balance" "$audio_devices" "$sound_feedback" "$quick_devices"; do
core_files=("$audio_devices" "$device_list" "$device_row" "$balance")
page_components=("$sound_page" "$device_list" "$device_row" "$application_mixer"
"$application_row" "$balance" "$badge" "$capture_row" "$channel_strip"
"$theme_row" "$volume_row")
for file in "${page_components[@]}" "$dictation_page" "$audio_devices" \
"$sound_feedback" "$sound_cards" "$sound_routing" "$sound_defaults" \
"$quick_devices" "$quick_slider"; do
[[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}"
done
@@ -73,12 +117,121 @@ rg -Fq 'root.node.audio.volumes = next;' "$balance" || fail 'balance does not wr
[[ "$(rg -c 'SoundDeviceList \{' "$sound_page")" -eq 2 ]] || fail 'Sound page does not expose output and input device lists'
rg -Fq 'AudioBalance {' "$sound_page" || fail 'Sound page has no output balance control'
rg -Fq 'ApplicationMixer {' "$sound_page" || fail 'Sound page has no Applications mixer'
rg -Fq 'label: "Device profiles"' "$sound_page" || fail 'advanced handoff is not limited to device profiles'
rg -Fq 'SystemSettings.openGnomePanel("sound")' "$sound_page" || fail 'advanced GNOME Sound handoff was removed'
rg -Fq 'SoundFeedback.setEventSounds(checked)' "$sound_page" || fail 'event sounds are not controllable'
rg -Fq 'SoundFeedback.setInputFeedback(checked)' "$sound_page" || fail 'input feedback sounds are not controllable'
rg -Fq 'org.gnome.desktop.sound' "$sound_feedback" || fail 'sound feedback does not use the desktop sound schema'
# ── Device profiles are native now ───────────────────────────────────────────
# This page used to end in a button that opened GNOME's Sound panel, because
# reading a card's profiles means shelling out and the page may not. SoundCards
# does the shelling, so the handoff is gone -- and it has to stay gone, because
# an "Open panel" button next to a working dropdown is a second answer to the
# same question.
! rg -Fq 'openGnomePanel("sound")' "$sound_page" \
|| fail 'the GNOME Sound handoff came back, next to the profile dropdown that replaced it'
! rg -Fq 'label: "Device profiles"' "$sound_page" \
|| fail 'device profiles are an ActionRow again rather than the native card'
rg -Fq 'title: "Device profiles"' "$sound_page" || fail 'the Device profiles card is gone'
rg -Fq 'SoundCards.cards' "$sound_page" || fail 'the profile card reads no cards'
rg -Fq 'SoundCards.setProfile(' "$sound_page" || fail 'the profile dropdown writes nothing'
rg -Fq 'SoundCards.refresh();' "$sound_page" || fail 'device profiles are never re-read on page open'
rg -Fq 'SoundDefaults.refresh();' "$sound_page" || fail 'the configured defaults are never re-read on page open'
rg -Fq 'SoundCards.lastError' "$sound_page" \
|| fail 'a failed profile read degrades nothing, so the card would show an empty list instead of a reason'
# ── The microphone, and who is holding it ────────────────────────────────────
rg -Fq 'PwNodeType.AudioInStream' "$audio_devices" \
|| fail 'capture streams are not filtered by PipeWire type'
rg -Fq 'captureApplications' "$audio_devices" || fail 'AudioDevices exposes no capture applications'
rg -Fq 'AudioDevices.captureApplications' "$capture_row" \
|| fail 'the microphone row does not read the grouped capture applications'
rg -Fq 'AudioDevices.setApplicationMuted(' "$capture_row" \
|| fail 'an application holding the microphone cannot be muted from the row'
rg -Fq 'PwObjectTracker {' "$capture_row" \
|| fail 'capture nodes are untracked, so their mute state reads false and the write is swallowed'
# Nothing listening means no row, not an empty one. "No applications are using
# the microphone" is a sentence nobody needs on a page they opened to change an
# output device.
rg -Fq 'visible: root.users.length > 0' "$capture_row" \
|| fail 'the microphone row renders itself when nothing is listening'
rg -Fq 'SoundTest.startMicTest(' "$sound_page" || fail 'there is no microphone test'
rg -Fq 'SoundTest.micTestState' "$sound_page" \
|| fail 'the microphone test button never says what it is doing'
# ── The ghost row ────────────────────────────────────────────────────────────
# A configured device that is not present renders last, dimmed, and inert. The
# two halves that make it honest: it is keyed off the *configured* name, and it
# only appears when no present node carries that name.
rg -Fq 'SoundDefaults.absent(root.output)' "$device_list" \
|| fail 'the device list does not ask which configured device is missing, so it can draw no ghost row'
rg -Fq 'ghost: true' "$device_list" || fail 'the list has no ghost row at all'
rg -Fq 'Returns when connected' "$device_row" || fail 'the ghost row does not say why it is there'
# Non-interactive: selecting a device that is not here would ask PipeWire to
# make a node that does not exist the default.
python3 - "$device_row" <<'PY' || fail 'the ghost row is selectable, so a device that is not here can be chosen'
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
for handler in ("TapHandler", "HoverHandler"):
for match in re.finditer(handler + r" \{(?P<body>.*?)\n \}", source, re.S):
if "!root.ghost" not in match.group("body"):
raise SystemExit(1)
if "AudioDevices.select(" not in source:
raise SystemExit(1)
PY
# The ghost row renders after the present devices, not among them.
python3 - "$device_list" <<'PY' || fail 'the ghost row is not last in the list'
import sys
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
repeater = next(i for i, line in enumerate(lines) if line.strip().startswith("Repeater"))
ghost = next(i for i, line in enumerate(lines) if "ghost: true" in line)
if ghost < repeater:
raise SystemExit(1)
PY
# ── Badges ───────────────────────────────────────────────────────────────────
# Native, off the node's own properties. A transport badge derived from the
# device name would be a guess.
rg -Fq '"device.api"' "$device_row" || fail 'device badges are not read from the node properties'
rg -Fq '"raop"' "$device_row" || fail 'AirPlay devices carry no badge'
rg -Fq '"bluez5"' "$device_row" || fail 'Bluetooth devices carry no badge'
# ── Over-amplification ───────────────────────────────────────────────────────
# One ceiling, one preference, and the input is never part of it: a microphone
# above 100% is gain on noise, not loudness.
rg -Fq 'overAmplification' "$sound_page" || fail 'the Sound page has no over-amplification control'
rg -Fq '1.5 : 1' "$sound_page" || fail 'the output slider maximum is not gated on the preference'
rg -Fq 'root.output' "$quick_slider" \
|| fail "Quick Settings' slider does not distinguish the sink from the microphone"
rg -Fq '1.5 : 1' "$quick_slider" || fail "Quick Settings' output slider cannot over-amplify"
python3 - "$quick_slider" <<'PY' || fail 'over-amplification is not restricted to the output in Quick Settings'
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
match = re.search(r"property real maximum:(?P<body>.*?)\n\n", source, re.S)
if not match or "root.output" not in match.group("body"):
raise SystemExit(1)
PY
rg -Fq 'root.maximum > 1' "$volume_row" \
|| fail 'the region past 100% is not marked, so 150% looks like a full slider'
# ── Per-application routing ──────────────────────────────────────────────────
rg -Fq 'SoundRouting.moveApplication(' "$application_row" \
|| fail 'an application cannot be sent to another output'
rg -Fq 'SoundRouting.routeToDefault(' "$application_row" \
|| fail 'an application cannot be handed back to the system default'
rg -Fq 'SoundRouting.currentSinkFor(' "$application_row" \
|| fail 'the output picker does not say where the application is playing'
rg -Fq 'label: "System default"' "$application_row" \
|| fail 'following the system default is not an option anyone can pick'
# ── Dictation lives on its own page under Input ──────────────────────────────
# One page owns the dictation controls. Two would mean two setup buttons
# driving the same one-time install, and whichever one someone found second
@@ -108,12 +261,28 @@ rg -Fq 'ShellState.openSettings("sound")' "$dictation_page" \
! rg -Fq 'SoundDeviceList {' "$dictation_page" \
|| fail 'DictationPage grew its own device picker -- there is one input device, and two places to change it disagree'
# Native bindings are the supported path. Shelling out would race the service
# that owns these same objects and regress Quick Settings coherence.
if rg -q '\b(Process|pactl|wpctl)\b' "$audio_devices" "$device_list" "$device_row" "$balance"; then
# ── Native bindings are the supported path ───────────────────────────────────
# Shelling out here would race the service that owns these same objects and
# regress Quick Settings coherence.
if rg -q '\b(Process|pactl|wpctl|pw-metadata)\b' "${core_files[@]}"; then
fail 'Sound controls bypass the Quickshell PipeWire service'
fi
# The ban extends to every component the rebuilt page is made of. Five new ones
# landed with it, and the cheapest way for any of them to get something the
# PipeWire bindings do not expose is a Process nobody noticed.
for component in "${page_components[@]}"; do
if rg -q '\bProcess\b' "$component"; then
fail "${component#"$settings/"} shells out -- anything that does belongs in a sibling singleton beside SoundTest"
fi
done
# And the singletons that are allowed to shell out are the ones the spec names.
for service in "$sound_cards" "$sound_routing" "$sound_defaults"; do
rg -Fq 'pragma Singleton' "$service" \
|| fail "${service#"$services/"} is not a singleton, so the page would hold its own copy of it"
done
printf 'sound page static contract: PASS\n'
# Instantiate the complete page against the real, read-only PipeWire graph.
@@ -140,6 +309,26 @@ jq -e '.ready == true and .outputs > 0 and .inputs > 0
<<<"$status" >/dev/null \
|| fail "real PipeWire graph was not represented: $status"
# Capture grouping is the playback grouping applied to the other direction, so
# the same invariant holds: every node in a group is an input stream, and an
# application recording on three streams is one entry.
jq -e '.captureApplications >= 0 and .captureTypesValid == true' <<<"$status" >/dev/null \
|| fail "capture applications were grouped from the wrong stream type: $status"
jq -e '.captureRowVisible == (.captureApplications > 0)' <<<"$status" >/dev/null \
|| fail "the microphone row does not follow whether anything is listening: $status"
# The ghost row, against a configured sink this session cannot possibly have.
# The read is a subprocess, so it lands after the first status call.
for _ in $(seq 1 60); do
status="$(qs_for_harness ipc call sound-page-test status)"
[[ "$(jq -r .ghostVisible <<<"$status")" == "true" ]] && break
sleep 0.1
done
jq -e '.ghostVisible == true' <<<"$status" >/dev/null \
|| fail "a configured output that is not present did not produce a ghost row: $status"
jq -e '.ghostLabel == "Bluetooth device (AA:BB:CC:11:22:33)"' <<<"$status" >/dev/null \
|| fail "the ghost row is labelled with the raw node name: $status"
if rg -n 'ReferenceError|TypeError|Binding loop|Unable to assign|Cannot assign|PwObjectTracker' "$shell_log"; then
fail 'Sound page emitted a QML runtime warning'
fi
+301
View File
@@ -0,0 +1,301 @@
#!/usr/bin/env bash
# Sending one application to a different output is the thing every mixer on
# every other desktop has and Panama's did not. SoundRouting is the singleton
# that does it, because moving a stream means `pactl move-sink-input` and the
# Sound page is banned from shelling out.
#
# The parts that are easy to get wrong and impossible to notice:
#
# * an application is a *group* of streams -- a browser playing two tabs has
# two sink inputs, and moving one of them is worse than moving none;
# * pactl wants the stream's `object.serial`, not its node id, and the two
# are different numbers that both look plausible in a log;
# * a stream with no serial still has to move, on its node id;
# * an application that stopped playing between the click and the call must
# not turn into a pactl invocation with an empty argument;
# * putting an application *back* on the system default is not the same call
# in reverse -- moving it to the default sink pins it there, so it stops
# following the moment the default moves again.
#
# Runs against recording pactl and pw-metadata stubs. No real stream is ever
# moved and no real metadata key is ever deleted.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
service="$repo_dir/config/dot/quickshell/services/SoundRouting.qml"
harness="$repo_dir/config/dot/quickshell/sound-services-harness.qml"
fixture="$(mktemp -d /tmp/panama-sound-routing.XXXXXX)"
state_home="$fixture/state"
shell_log="$fixture/quickshell.log"
harness_pid=""
fail() {
printf 'sound routing contract: %s\n' "$1" >&2
[[ -s "$shell_log" ]] && sed -n '1,80p' "$shell_log" >&2
exit 1
}
[[ -f "$service" ]] || fail 'SoundRouting.qml is missing'
[[ -f "$harness" ]] || fail 'sound-services-harness.qml is missing'
mkdir -p "$fixture/bin" "$state_home"
cat >"$fixture/bin/pactl" <<'STUB'
#!/usr/bin/env bash
printf 'pactl' >>"$PANAMA_SOUND_PACTL_LOG"
printf ' <%s>' "$@" >>"$PANAMA_SOUND_PACTL_LOG"
printf '\n' >>"$PANAMA_SOUND_PACTL_LOG"
for arg in "$@"; do
[[ "$arg" == "cards" ]] && { printf '[]\n'; exit 0; }
done
if [[ "$(cat "$PANAMA_SOUND_MOVE_MODE" 2>/dev/null || printf 'good')" == "failing" ]]; then
printf 'Failure: No such entity\n' >&2
exit 1
fi
exit 0
STUB
chmod +x "$fixture/bin/pactl"
cat >"$fixture/bin/pw-metadata" <<'STUB'
#!/usr/bin/env bash
printf 'pw-metadata' >>"$PANAMA_SOUND_PACTL_LOG"
printf ' <%s>' "$@" >>"$PANAMA_SOUND_PACTL_LOG"
printf '\n' >>"$PANAMA_SOUND_PACTL_LOG"
exit 0
STUB
chmod +x "$fixture/bin/pw-metadata"
# SoundCards and SoundDefaults share this harness and would otherwise read the
# live daemon. Both take a fixture path; give them inert ones so every line in
# the log below came from SoundRouting.
printf '[]\n' >"$fixture/cards.json"
: >"$fixture/defaults"
export PANAMA_SOUND_PACTL_LOG="$fixture/pactl.log"
export PANAMA_SOUND_MOVE_MODE="$fixture/mode"
export PANAMA_SOUND_CARDS_FIXTURE="$fixture/cards.json"
export PANAMA_SOUND_DEFAULTS_FIXTURE="$fixture/defaults"
: >"$PANAMA_SOUND_PACTL_LOG"
printf 'good\n' >"$PANAMA_SOUND_MOVE_MODE"
instances_for_harness() {
qs list --all 2>/dev/null | awk -v expected="$harness" '
/^Instance / { pid = "" }
/^[[:space:]]*Process ID:/ { pid = $3 }
/^[[:space:]]*Config path:/ {
path = $0
sub(/^[[:space:]]*Config path: /, "", path)
if (path == expected && pid ~ /^[0-9]+$/) print pid
}
'
}
cleanup() {
if [[ "$harness_pid" =~ ^[0-9]+$ ]] && kill -0 "$harness_pid" 2>/dev/null; then
kill "$harness_pid" 2>/dev/null || true
for _ in $(seq 1 40); do
kill -0 "$harness_pid" 2>/dev/null || break
sleep 0.05
done
fi
rm -rf "$fixture"
}
trap cleanup EXIT
run() {
PATH="$fixture/bin:$PATH" \
XDG_STATE_HOME="$state_home" \
PANAMA_SOUND_PACTL_LOG="$PANAMA_SOUND_PACTL_LOG" \
PANAMA_SOUND_MOVE_MODE="$PANAMA_SOUND_MOVE_MODE" \
PANAMA_SOUND_CARDS_FIXTURE="$PANAMA_SOUND_CARDS_FIXTURE" \
PANAMA_SOUND_DEFAULTS_FIXTURE="$PANAMA_SOUND_DEFAULTS_FIXTURE" \
qs -p "$harness" "$@"
}
ipc() {
if [[ "$harness_pid" =~ ^[0-9]+$ ]]; then
run ipc --pid "$harness_pid" "$@"
else
run ipc "$@"
fi
}
await_log() {
local needle="$1"
for _ in $(seq 1 60); do
grep -Fq "$needle" "$PANAMA_SOUND_PACTL_LOG" && return 0
sleep 0.1
done
return 1
}
run --daemonize >"$shell_log" 2>&1 || fail 'sound services harness did not launch'
for _ in $(seq 1 60); do
harness_pid="$(instances_for_harness | head -1)"
if [[ "$harness_pid" =~ ^[0-9]+$ ]] \
&& ipc show 2>/dev/null | rg -q '^target sound-services-test$'; then
break
fi
sleep 0.1
done
[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'sound services harness process did not start'
# ── Every stream in the group moves ──────────────────────────────────────────
: >"$PANAMA_SOUND_PACTL_LOG"
ipc call sound-services-test moveApplication twoStream \
alsa_output.pci-0000_00_1f.3.analog-stereo >/dev/null
await_log 'move-sink-input' \
|| fail 'moving an application never reached pactl'
for _ in $(seq 1 40); do
[[ "$(grep -Fc 'move-sink-input' "$PANAMA_SOUND_PACTL_LOG")" == "2" ]] && break
sleep 0.05
done
grep -Fq 'pactl <move-sink-input> <412> <alsa_output.pci-0000_00_1f.3.analog-stereo>' \
"$PANAMA_SOUND_PACTL_LOG" \
|| fail "the first stream was not moved by its object.serial: $(cat "$PANAMA_SOUND_PACTL_LOG")"
grep -Fq 'pactl <move-sink-input> <418> <alsa_output.pci-0000_00_1f.3.analog-stereo>' \
"$PANAMA_SOUND_PACTL_LOG" \
|| fail "the second stream of the same application stayed behind: $(cat "$PANAMA_SOUND_PACTL_LOG")"
# The node id is a different number that looks just as plausible in a log, and
# pactl will happily move whatever stream happens to carry it.
if grep -Eq 'move-sink-input> <(61|62)>' "$PANAMA_SOUND_PACTL_LOG"; then
fail "streams were moved by node id instead of object.serial: $(cat "$PANAMA_SOUND_PACTL_LOG")"
fi
state="$(ipc call sound-services-test routing)"
for _ in $(seq 1 40); do
state="$(ipc call sound-services-test routing)"
jq -e '.busy == false' >/dev/null <<<"$state" && break
sleep 0.1
done
jq -e '.busy == false and .lastError == ""' >/dev/null <<<"$state" \
|| fail "a successful move left the service busy or carrying an error: $state"
# ── A stream PipeWire never gave a serial still moves ────────────────────────
: >"$PANAMA_SOUND_PACTL_LOG"
ipc call sound-services-test moveApplication serialless \
alsa_output.pci-0000_00_1f.3.analog-stereo >/dev/null
await_log 'move-sink-input' || fail 'a serial-less stream was skipped entirely'
grep -Fq 'pactl <move-sink-input> <77> <alsa_output.pci-0000_00_1f.3.analog-stereo>' \
"$PANAMA_SOUND_PACTL_LOG" \
|| fail "a stream with no object.serial did not fall back to its node id: $(cat "$PANAMA_SOUND_PACTL_LOG")"
# ── Nothing to move, nothing to run ──────────────────────────────────────────
# An application that stopped playing between the click and the call is the
# ordinary case, not an error, and it must not become `pactl move-sink-input
# "" <sink>` -- which pactl answers by moving something else.
: >"$PANAMA_SOUND_PACTL_LOG"
ipc call sound-services-test moveApplication empty \
alsa_output.pci-0000_00_1f.3.analog-stereo >/dev/null || true
ipc call sound-services-test moveApplication twoStream "" >/dev/null || true
sleep 0.3
if grep -Fq 'move-sink-input' "$PANAMA_SOUND_PACTL_LOG"; then
fail "an empty group or an unnamed sink still started a move: $(cat "$PANAMA_SOUND_PACTL_LOG")"
fi
# ── Following the system default again ───────────────────────────────────────
# Not the same call in reverse. `move-sink-input` to the current default *pins*
# the stream there, so it would stop following and quietly stay behind the next
# time the default moved -- the exact bug the button exists to undo. The pin
# lives as a `target.object` key on the stream's node id in PipeWire's default
# metadata, and releasing it means deleting that key.
#
# Note the id asymmetry, which is the other way to get this wrong: pactl speaks
# object serials (412, 418) and pw-metadata speaks node ids (61, 62). They are
# different numbers for the same stream and both look right in a log.
: >"$PANAMA_SOUND_PACTL_LOG"
ipc call sound-services-test routeToDefault twoStream >/dev/null
await_log 'target.object' || fail 'returning an application to the default ran nothing'
for _ in $(seq 1 60); do
[[ "$(grep -Fc 'pw-metadata' "$PANAMA_SOUND_PACTL_LOG")" == "4" ]] && break
sleep 0.05
done
grep -Fq 'pw-metadata <-n> <default> <-d> <61> <target.object>' "$PANAMA_SOUND_PACTL_LOG" \
|| fail "the first stream's pin was not released: $(cat "$PANAMA_SOUND_PACTL_LOG")"
grep -Fq 'pw-metadata <-n> <default> <-d> <62> <target.object>' "$PANAMA_SOUND_PACTL_LOG" \
|| fail "the second stream of the same application stayed pinned: $(cat "$PANAMA_SOUND_PACTL_LOG")"
# Streams pinned by older tooling carry `target.node` instead, and a release
# that only deletes one of the two keys leaves half the population stuck.
grep -Fq 'pw-metadata <-n> <default> <-d> <61> <target.node>' "$PANAMA_SOUND_PACTL_LOG" \
|| fail "the legacy target.node pin was left in place: $(cat "$PANAMA_SOUND_PACTL_LOG")"
if grep -Fq 'move-sink-input' "$PANAMA_SOUND_PACTL_LOG"; then
fail "returning to the default moved the stream to a sink, which pins it there instead of releasing it: $(cat "$PANAMA_SOUND_PACTL_LOG")"
fi
if grep -Eq 'pw-metadata <.*> <(412|418)>' "$PANAMA_SOUND_PACTL_LOG"; then
fail "pw-metadata was given an object serial where it wants a node id: $(cat "$PANAMA_SOUND_PACTL_LOG")"
fi
: >"$PANAMA_SOUND_PACTL_LOG"
ipc call sound-services-test routeToDefault empty >/dev/null || true
sleep 0.3
[[ ! -s "$PANAMA_SOUND_PACTL_LOG" ]] \
|| fail "an empty group still ran a command: $(cat "$PANAMA_SOUND_PACTL_LOG")"
# ── Where an application is playing ──────────────────────────────────────────
# Fixture streams are not in the real graph, so nothing links them to a sink.
# That is exactly the "follows the system default" case, and it reads as an
# empty string rather than "unknown", a null, or a thrown error.
[[ "$(ipc call sound-services-test currentSinkFor twoStream)" == "" ]] \
|| fail 'an unlinked stream did not read as following the system default'
[[ "$(ipc call sound-services-test currentSinkFor empty)" == "" ]] \
|| fail 'an application with no streams did not read as following the system default'
# ── pactl refusing ───────────────────────────────────────────────────────────
printf 'failing\n' >"$PANAMA_SOUND_MOVE_MODE"
: >"$PANAMA_SOUND_PACTL_LOG"
ipc call sound-services-test moveApplication twoStream \
alsa_output.pci-0000_00_1f.3.analog-stereo >/dev/null
for _ in $(seq 1 60); do
state="$(ipc call sound-services-test routing)"
[[ -n "$(jq -r '.lastError' <<<"$state")" ]] && break
sleep 0.1
done
jq -e '.lastError != "" and .busy == false' >/dev/null <<<"$state" \
|| fail "a refused move was neither reported nor finished: $state"
# An error is a state, not a terminal one.
printf 'good\n' >"$PANAMA_SOUND_MOVE_MODE"
ipc call sound-services-test moveApplication twoStream \
alsa_output.pci-0000_00_1f.3.analog-stereo >/dev/null
for _ in $(seq 1 60); do
state="$(ipc call sound-services-test routing)"
jq -e '.lastError == "" and .busy == false' >/dev/null <<<"$state" && break
sleep 0.1
done
jq -e '.lastError == "" and .busy == false' >/dev/null <<<"$state" \
|| fail "the service never cleared the error from a move that then succeeded: $state"
if rg -n 'ReferenceError|TypeError|Binding loop|Unable to assign|Cannot assign' "$shell_log"; then
fail 'sound services harness emitted a QML runtime warning'
fi
# ── Static ───────────────────────────────────────────────────────────────────
rg -Fq 'pragma Singleton' "$service" || fail 'SoundRouting is not a singleton'
# There is more than one way to put an application back on the system default
# and they do not behave the same. Whichever one is here, the next person has
# to be able to find out why, without running it.
python3 - "$service" <<'PY' || fail 'routeToDefault does not say which mechanism it uses'
import re
import sys
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
index = next((i for i, line in enumerate(lines)
if re.search(r'\bfunction\s+routeToDefault\b', line)), None)
if index is None:
raise SystemExit("routeToDefault is missing")
above = [line.strip() for line in lines[max(0, index - 12):index]]
if not any(line.startswith("//") for line in above):
raise SystemExit("routeToDefault has no comment explaining its mechanism")
PY
trap - EXIT
cleanup
printf 'sound routing contract: PASS\n'