diff --git a/README.md b/README.md index 22dece1..57e901c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config/dot/quickshell/audio-streams-harness.qml b/config/dot/quickshell/audio-streams-harness.qml index a7bdc1d..13bde07 100644 --- a/config/dot/quickshell/audio-streams-harness.qml +++ b/config/dot/quickshell/audio-streams-harness.qml @@ -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({ diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index 5fe3b66..651c9b8 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -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 diff --git a/config/dot/quickshell/config/Settings.qml b/config/dot/quickshell/config/Settings.qml index 96e0c4a..26b86fd 100644 --- a/config/dot/quickshell/config/Settings.qml +++ b/config/dot/quickshell/config/Settings.qml @@ -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. diff --git a/config/dot/quickshell/modules/quicksettings/AudioSlider.qml b/config/dot/quickshell/modules/quicksettings/AudioSlider.qml index 0de497d..4b9fe95 100644 --- a/config/dot/quickshell/modules/quicksettings/AudioSlider.qml +++ b/config/dot/quickshell/modules/quicksettings/AudioSlider.qml @@ -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 diff --git a/config/dot/quickshell/modules/settings/ApplicationVolumeRow.qml b/config/dot/quickshell/modules/settings/ApplicationVolumeRow.qml index e5a8a1c..d680ea5 100644 --- a/config/dot/quickshell/modules/settings/ApplicationVolumeRow.qml +++ b/config/dot/quickshell/modules/settings/ApplicationVolumeRow.qml @@ -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; + } + } + } + } + } } diff --git a/config/dot/quickshell/modules/settings/AudioBalance.qml b/config/dot/quickshell/modules/settings/AudioBalance.qml index 5a0965f..95ac9af 100644 --- a/config/dot/quickshell/modules/settings/AudioBalance.qml +++ b/config/dot/quickshell/modules/settings/AudioBalance.qml @@ -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] : [] diff --git a/config/dot/quickshell/modules/settings/SoundBadge.qml b/config/dot/quickshell/modules/settings/SoundBadge.qml new file mode 100644 index 0000000..ca665dc --- /dev/null +++ b/config/dot/quickshell/modules/settings/SoundBadge.qml @@ -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 + } +} diff --git a/config/dot/quickshell/modules/settings/SoundCaptureRow.qml b/config/dot/quickshell/modules/settings/SoundCaptureRow.qml new file mode 100644 index 0000000..17bcd25 --- /dev/null +++ b/config/dot/quickshell/modules/settings/SoundCaptureRow.qml @@ -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) + } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/SoundChannelStrip.qml b/config/dot/quickshell/modules/settings/SoundChannelStrip.qml new file mode 100644 index 0000000..c8c133b --- /dev/null +++ b/config/dot/quickshell/modules/settings/SoundChannelStrip.qml @@ -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) + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/SoundDeviceList.qml b/config/dot/quickshell/modules/settings/SoundDeviceList.qml index 388aa61..0d4a405 100644 --- a/config/dot/quickshell/modules/settings/SoundDeviceList.qml +++ b/config/dot/quickshell/modules/settings/SoundDeviceList.qml @@ -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 diff --git a/config/dot/quickshell/modules/settings/SoundDeviceRow.qml b/config/dot/quickshell/modules/settings/SoundDeviceRow.qml index 3188140..3848bd9 100644 --- a/config/dot/quickshell/modules/settings/SoundDeviceRow.qml +++ b/config/dot/quickshell/modules/settings/SoundDeviceRow.qml @@ -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 } } } diff --git a/config/dot/quickshell/modules/settings/SoundPage.qml b/config/dot/quickshell/modules/settings/SoundPage.qml index 0f430e6..9d49de6 100644 --- a/config/dot/quickshell/modules/settings/SoundPage.qml +++ b/config/dot/quickshell/modules/settings/SoundPage.qml @@ -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 } } } diff --git a/config/dot/quickshell/modules/settings/SoundThemeRow.qml b/config/dot/quickshell/modules/settings/SoundThemeRow.qml new file mode 100644 index 0000000..6da0ca3 --- /dev/null +++ b/config/dot/quickshell/modules/settings/SoundThemeRow.qml @@ -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; + } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/SoundVolumeRow.qml b/config/dot/quickshell/modules/settings/SoundVolumeRow.qml new file mode 100644 index 0000000..53616b3 --- /dev/null +++ b/config/dot/quickshell/modules/settings/SoundVolumeRow.qml @@ -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) + } +} diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 3429ea0..df51a9a 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -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 diff --git a/config/dot/quickshell/scripts/panama-osd b/config/dot/quickshell/scripts/panama-osd index 245d9ff..9e0e76e 100755 --- a/config/dot/quickshell/scripts/panama-osd +++ b/config/dot/quickshell/scripts/panama-osd @@ -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 diff --git a/config/dot/quickshell/services/AudioDevices.qml b/config/dot/quickshell/services/AudioDevices.qml index 4128151..b85741a 100644 --- a/config/dot/quickshell/services/AudioDevices.qml +++ b/config/dot/quickshell/services/AudioDevices.qml @@ -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 { diff --git a/config/dot/quickshell/services/AudioStreams.js b/config/dot/quickshell/services/AudioStreams.js index 9a33753..330bb5c 100644 --- a/config/dot/quickshell/services/AudioStreams.js +++ b/config/dot/quickshell/services/AudioStreams.js @@ -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; diff --git a/config/dot/quickshell/services/Notifs.qml b/config/dot/quickshell/services/Notifs.qml index 0d73275..04b5cfe 100644 --- a/config/dot/quickshell/services/Notifs.qml +++ b/config/dot/quickshell/services/Notifs.qml @@ -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 diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index fbf485b..d36b99d 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -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" }, diff --git a/config/dot/quickshell/services/SoundCards.qml b/config/dot/quickshell/services/SoundCards.qml new file mode 100644 index 0000000..b7e7c05 --- /dev/null +++ b/config/dot/quickshell/services/SoundCards.qml @@ -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(); + } + } +} diff --git a/config/dot/quickshell/services/SoundDefaults.qml b/config/dot/quickshell/services/SoundDefaults.qml new file mode 100644 index 0000000..f5155d9 --- /dev/null +++ b/config/dot/quickshell/services/SoundDefaults.qml @@ -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() +} diff --git a/config/dot/quickshell/services/SoundFeedback.qml b/config/dot/quickshell/services/SoundFeedback.qml index 8563154..adeb337 100644 --- a/config/dot/quickshell/services/SoundFeedback.qml +++ b/config/dot/quickshell/services/SoundFeedback.qml @@ -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 directoryname 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() } diff --git a/config/dot/quickshell/services/SoundRouting.qml b/config/dot/quickshell/services/SoundRouting.qml new file mode 100644 index 0000000..6ea662f --- /dev/null +++ b/config/dot/quickshell/services/SoundRouting.qml @@ -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 `. 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 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 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 `: 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(); + } + } +} diff --git a/config/dot/quickshell/services/SoundTest.qml b/config/dot/quickshell/services/SoundTest.qml index 1930d59..2f3c624 100644 --- a/config/dot/quickshell/services/SoundTest.qml +++ b/config/dot/quickshell/services/SoundTest.qml @@ -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"; + } + } } diff --git a/config/dot/quickshell/sound-page-harness.qml b/config/dot/quickshell/sound-page-harness.qml index 27a0e8e..158eef8 100644 --- a/config/dot/quickshell/sound-page-harness.qml +++ b/config/dot/quickshell/sound-page-harness.qml @@ -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) : "" }); } } diff --git a/config/dot/quickshell/sound-services-harness.qml b/config/dot/quickshell/sound-services-harness.qml new file mode 100644 index 0000000..9eec9a5 --- /dev/null +++ b/config/dot/quickshell/sound-services-harness.qml @@ -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 + }); + } + } +} diff --git a/config/local/share/vicinae/scripts/settings-sound b/config/local/share/vicinae/scripts/settings-sound index f7f3602..9426c6f 100755 --- a/config/local/share/vicinae/scripts/settings-sound +++ b/config/local/share/vicinae/scripts/settings-sound @@ -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 diff --git a/docs/settings.md b/docs/settings.md index 731c3a5..8179883 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -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**
`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**
`overAmplification` | false | Lets the volume slider go to 150% — louder, at the cost of distortion on some hardware | +| **Volume-change blip**
`volumeChangeBlip` | true | A short click each time the volume keys move the output level | + ## themes Found on **Appearance**. diff --git a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md index ae80ac5..fc1ae99 100644 --- a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md +++ b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md @@ -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 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. diff --git a/docs/superpowers/specs/2026-08-24-sound-redesign.md b/docs/superpowers/specs/2026-08-24-sound-redesign.md new file mode 100644 index 0000000..f42c7db --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-sound-redesign.md @@ -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 ` 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 `. +- `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 0–1) 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. diff --git a/tests/quickshell/application-volume-contract b/tests/quickshell/application-volume-contract index c7c6fc8..65b2da9 100755 --- a/tests/quickshell/application-volume-contract +++ b/tests/quickshell/application-volume-contract @@ -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" diff --git a/tests/quickshell/osd-helper-contract b/tests/quickshell/osd-helper-contract index 46b8536..0bb9e77 100755 --- a/tests/quickshell/osd-helper-contract +++ b/tests/quickshell/osd-helper-contract @@ -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 <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>' @@ -148,6 +201,86 @@ WPCTL_OUTPUT='Volume: 0.72 [MUTED]' run_helper microphone toggle assert_line 'wpctl <@DEFAULT_AUDIO_SOURCE@> ' assert_line 'qs <72> <100> ' +# ── 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 <-l> <1.5> <@DEFAULT_AUDIO_SINK@> <6%+>' + +: >"$log" +OSD_CONFIG_HOME="$(settings_root overamp '{"overAmplification": true}')" \ + run_helper volume down 6 +assert_line 'wpctl <-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 <-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 <-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 <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>' + +: >"$log" +OSD_CONFIG_HOME="$(settings_root broken '{not json')" run_helper volume up 6 +assert_line 'wpctl <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>' +assert_line 'qs <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 <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 <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 <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>' +assert_line 'qs <58> <100> <58%>' + : >"$log" run_helper brightness up 5 assert_line 'brightnessctl <-m> <-c> ' diff --git a/tests/quickshell/sound-cards-contract b/tests/quickshell/sound-cards-contract new file mode 100755 index 0000000..712c59e --- /dev/null +++ b/tests/quickshell/sound-cards-contract @@ -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 ' \ + "$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' diff --git a/tests/quickshell/sound-defaults-contract b/tests/quickshell/sound-defaults-contract new file mode 100755 index 0000000..4c03787 --- /dev/null +++ b/tests/quickshell/sound-defaults-contract @@ -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" <"$fixture/metadata-unconfigured" <"$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" <"$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' diff --git a/tests/quickshell/sound-page-contract b/tests/quickshell/sound-page-contract index cd257e3..8dad129 100755 --- a/tests/quickshell/sound-page-contract +++ b/tests/quickshell/sound-page-contract @@ -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" < 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.*?)\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.*?)\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 diff --git a/tests/quickshell/sound-routing-contract b/tests/quickshell/sound-routing-contract new file mode 100755 index 0000000..e6981f0 --- /dev/null +++ b/tests/quickshell/sound-routing-contract @@ -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 <412> ' \ + "$PANAMA_SOUND_PACTL_LOG" \ + || fail "the first stream was not moved by its object.serial: $(cat "$PANAMA_SOUND_PACTL_LOG")" +grep -Fq 'pactl <418> ' \ + "$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 <77> ' \ + "$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 +# "" ` -- 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> <-d> <61> ' "$PANAMA_SOUND_PACTL_LOG" \ + || fail "the first stream's pin was not released: $(cat "$PANAMA_SOUND_PACTL_LOG")" +grep -Fq 'pw-metadata <-n> <-d> <62> ' "$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> <-d> <61> ' "$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'