Give Sound the whole story, and keep the buttons inside the card
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -151,7 +151,7 @@ docs/ Settings reference, and the design specs behind the work
|
||||
|
||||
## Tests
|
||||
|
||||
166 of them, under `tests/`. Run the lot, or a subset by pattern:
|
||||
169 of them, under `tests/`. Run the lot, or a subset by pattern:
|
||||
|
||||
```sh
|
||||
panama test # everything
|
||||
|
||||
@@ -75,6 +75,21 @@ ShellRoot {
|
||||
return AudioStreams.group(fixtureNodes, audioOutStreamFlag);
|
||||
}
|
||||
|
||||
// A throwaway two-stream group for the clamp cases. The shared fixture
|
||||
// above is mutated by the volume and mute tests, and a clamp assertion that
|
||||
// depended on which of those ran first would be worthless.
|
||||
function clampFixture(): var {
|
||||
return {
|
||||
key: "clamp",
|
||||
label: "Clamp",
|
||||
icon: "audio-x-generic-symbolic",
|
||||
nodes: [
|
||||
{ audio: { volume: 0.1, muted: true } },
|
||||
{ audio: { volume: 0.1, muted: true } }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "application-volume-test"
|
||||
|
||||
@@ -115,6 +130,31 @@ ShellRoot {
|
||||
});
|
||||
}
|
||||
|
||||
// Over-amplification is a preference, so the ceiling is an argument
|
||||
// rather than a constant -- this file stays Settings-free on purpose.
|
||||
function clampVolume(): string {
|
||||
const overAmp = clampFixture();
|
||||
const overAmpChanged = AudioStreams.setVolume(overAmp, 1.4, 1.5);
|
||||
const ceiling = clampFixture();
|
||||
AudioStreams.setVolume(ceiling, 2.5, 1.5);
|
||||
const defaultMax = clampFixture();
|
||||
AudioStreams.setVolume(defaultMax, 1.4);
|
||||
const floor = clampFixture();
|
||||
AudioStreams.setVolume(floor, -0.5, 1.5);
|
||||
const nonNumeric = clampFixture();
|
||||
const nonNumericChanged = AudioStreams.setVolume(nonNumeric, "loud", 1.5);
|
||||
return JSON.stringify({
|
||||
overAmpChanged,
|
||||
overAmp: overAmp.nodes.map(node => node.audio.volume),
|
||||
overAmpMuted: overAmp.nodes.map(node => node.audio.muted),
|
||||
ceiling: ceiling.nodes.map(node => node.audio.volume),
|
||||
defaultMax: defaultMax.nodes.map(node => node.audio.volume),
|
||||
floor: floor.nodes.map(node => node.audio.volume),
|
||||
nonNumericChanged,
|
||||
nonNumeric: nonNumeric.nodes.map(node => node.audio.volume)
|
||||
});
|
||||
}
|
||||
|
||||
function serviceSummary(): string {
|
||||
const applications = AudioDevices.applications;
|
||||
return JSON.stringify({
|
||||
|
||||
@@ -1487,6 +1487,24 @@ Singleton {
|
||||
detail: "Maximum notification banners shown at once"
|
||||
},
|
||||
|
||||
// ── Sound ───────────────────────────────────────────────────────────
|
||||
// The two audio preferences that are Panama's own. Everything else on
|
||||
// the Sound page is live PipeWire or a GNOME desktop key, and belongs
|
||||
// to the system rather than to this file.
|
||||
//
|
||||
// Both are read by scripts/panama-osd as well as by the shell, so the
|
||||
// volume keys behave the same whether the panel is open or not.
|
||||
{
|
||||
key: "overAmplification", type: "bool", def: false, group: "sound",
|
||||
label: "Over-amplification",
|
||||
detail: "Lets the volume slider go to 150% — louder, at the cost of distortion on some hardware"
|
||||
},
|
||||
{
|
||||
key: "volumeChangeBlip", type: "bool", def: true, group: "sound",
|
||||
label: "Volume-change blip",
|
||||
detail: "A short click each time the volume keys move the output level"
|
||||
},
|
||||
|
||||
// ── Capture ─────────────────────────────────────────────────────────
|
||||
// Directories and encoder arguments are enums rather than free text:
|
||||
// both are handed to a recorder process, and an arbitrary string there
|
||||
|
||||
@@ -83,6 +83,13 @@ Singleton {
|
||||
readonly property int notificationHistoryLimit: DesktopPreferences.get("notificationHistoryLimit")
|
||||
readonly property int maxVisibleToasts: DesktopPreferences.get("maxVisibleToasts")
|
||||
|
||||
// ── Sound ───────────────────────────────────────────────────────────────
|
||||
// Over-amplification is the clamp ceiling for output volume: off means 1.0,
|
||||
// on means 1.5. Every slider and the volume keys read the same switch, so
|
||||
// the ceiling cannot differ depending on where you changed the volume from.
|
||||
readonly property bool overAmplification: DesktopPreferences.get("overAmplification")
|
||||
readonly property bool volumeChangeBlip: DesktopPreferences.get("volumeChangeBlip")
|
||||
|
||||
// ── Focus ──────────────────────────────────────────────────────────────
|
||||
// One deliberate default rather than a preset picker: quick settings and
|
||||
// the keyboard shortcut should start a useful session in one action.
|
||||
|
||||
@@ -25,6 +25,10 @@ Item {
|
||||
readonly property real volume: root.node && root.node.audio ? root.node.audio.volume : 0
|
||||
readonly property bool muted: root.node && root.node.audio ? root.node.audio.muted : false
|
||||
|
||||
// Over-amplification extends the output to 150%. The input is never
|
||||
// extended: a microphone above 100% is gain on noise, not loudness.
|
||||
readonly property real maximum: root.output && Settings.overAmplification ? 1.5 : 1
|
||||
|
||||
PwObjectTracker {
|
||||
objects: root.node ? [root.node] : []
|
||||
}
|
||||
@@ -57,21 +61,46 @@ Item {
|
||||
}
|
||||
|
||||
ValueSlider {
|
||||
id: slider
|
||||
anchors.left: muteButton.right
|
||||
anchors.leftMargin: 6
|
||||
anchors.right: chevron.left
|
||||
anchors.rightMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
value: root.muted ? 0 : root.volume
|
||||
value: root.muted ? 0 : root.volume / root.maximum
|
||||
onMoved: v => {
|
||||
if (!root.node || !root.node.audio)
|
||||
return;
|
||||
// Nudging the slider is also how you unmute, same as GNOME.
|
||||
root.node.audio.muted = false;
|
||||
root.node.audio.volume = v;
|
||||
root.node.audio.volume = v * root.maximum;
|
||||
}
|
||||
}
|
||||
|
||||
// Everything above 100%, marked over the track so the loud end of the
|
||||
// slider is visibly the loud end rather than more of the same.
|
||||
Rectangle {
|
||||
anchors.right: slider.right
|
||||
anchors.verticalCenter: slider.verticalCenter
|
||||
width: root.maximum > 1 ? slider.width * (1 - 1 / root.maximum) : 0
|
||||
height: 10
|
||||
radius: 2
|
||||
visible: root.maximum > 1
|
||||
color: Theme.alpha(Theme.warn, 0.30)
|
||||
border.width: 0
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.verticalCenter: slider.verticalCenter
|
||||
x: slider.x + slider.width / root.maximum - 1
|
||||
width: 2
|
||||
height: 16
|
||||
radius: 1
|
||||
visible: root.maximum > 1
|
||||
color: Theme.alpha(Theme.warn, 0.7)
|
||||
border.width: 0
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: chevron
|
||||
anchors.right: parent.right
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
// One application's own level, and where it plays.
|
||||
//
|
||||
// The destination is part of the row because it is part of the same question:
|
||||
// an application is too loud, or it is coming out of the wrong speakers, and
|
||||
// both are answered here rather than in a mixer somebody else ships. Picking a
|
||||
// device moves every stream the application owns; "System default" hands it
|
||||
// back to following whatever the Output card says.
|
||||
|
||||
import Quickshell.Services.Pipewire
|
||||
import QtQuick
|
||||
|
||||
@@ -11,11 +19,25 @@ Rectangle {
|
||||
|
||||
required property var application
|
||||
|
||||
// Open only while a destination is being chosen. The row is otherwise one
|
||||
// line tall, because the list of sinks is longer than the mixer usually is.
|
||||
property bool picking: false
|
||||
|
||||
readonly property real volume: AudioDevices.applicationVolume(root.application)
|
||||
readonly property bool muted: AudioDevices.applicationMuted(root.application)
|
||||
|
||||
// "" means the application follows the default sink rather than naming one.
|
||||
readonly property string sinkName: String(SoundRouting.currentSinkFor(root.application) ?? "")
|
||||
readonly property string sinkLabel: {
|
||||
if (root.sinkName === "")
|
||||
return "System default";
|
||||
const node = AudioDevices.outputs.find(candidate =>
|
||||
String(candidate?.name ?? "") === root.sinkName);
|
||||
return node ? AudioDevices.label(node) : root.sinkName;
|
||||
}
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
implicitHeight: 78
|
||||
implicitHeight: 78 + (root.picking ? sinkMenu.implicitHeight + 6 : 0)
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.alpha(Theme.fg, 0.025)
|
||||
border.width: 1
|
||||
@@ -37,10 +59,20 @@ Rectangle {
|
||||
tint: Theme.fg
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: sinkButton
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: applicationIcon.verticalCenter
|
||||
text: root.sinkLabel + (root.picking ? " ▴" : " ▾")
|
||||
enabled: !SoundRouting.busy
|
||||
onClicked: root.picking = !root.picking
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: applicationIcon.right
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: parent.right
|
||||
anchors.right: sinkButton.left
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: applicationIcon.verticalCenter
|
||||
spacing: 1
|
||||
@@ -69,8 +101,8 @@ Rectangle {
|
||||
id: muteButton
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 8
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 6
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 42
|
||||
size: 30
|
||||
iconSize: 17
|
||||
icon: root.muted || root.volume <= 0.001
|
||||
@@ -103,4 +135,85 @@ Rectangle {
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
horizontalAlignment: Text.AlignRight
|
||||
}
|
||||
|
||||
// The destinations: following the default, or any present output by name.
|
||||
Column {
|
||||
id: sinkMenu
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 78
|
||||
visible: root.picking
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: [{
|
||||
name: "",
|
||||
label: "System default"
|
||||
}].concat(AudioDevices.outputs.map(node => ({
|
||||
name: String(node?.name ?? ""),
|
||||
label: AudioDevices.label(node)
|
||||
})))
|
||||
|
||||
Rectangle {
|
||||
id: option
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property bool chosen: String(option.modelData.name) === root.sinkName
|
||||
|
||||
width: sinkMenu.width
|
||||
implicitHeight: 30
|
||||
radius: 8
|
||||
color: optionHover.hovered
|
||||
? Theme.alpha(Theme.fg, 0.07)
|
||||
: "transparent"
|
||||
border.width: 0
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: mark.left
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: String(option.modelData.label)
|
||||
color: option.chosen ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
id: mark
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: option.chosen
|
||||
text: "✓"
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: optionHover
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: {
|
||||
if (option.modelData.name === "")
|
||||
SoundRouting.routeToDefault(root.application);
|
||||
else
|
||||
SoundRouting.moveApplication(root.application, String(option.modelData.name));
|
||||
root.picking = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ SettingRow {
|
||||
detail: "Adjust the left and right channels"
|
||||
controlWidth: 270
|
||||
visible: root.available
|
||||
divider: false
|
||||
|
||||
PwObjectTracker {
|
||||
objects: root.node ? [root.node] : []
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// A pill beside a device name: how the device is attached, or why it is not
|
||||
// here at all.
|
||||
//
|
||||
// Nine outputs named after their chipsets look alike in a list. Which of them
|
||||
// arrives over the network, over Bluetooth, or is simply away in its case is
|
||||
// the fact that tells them apart, so it is said next to the name rather than
|
||||
// left to be inferred from a subtitle.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string text: ""
|
||||
// The token the badge is drawn from -- cyan for network devices, the accent
|
||||
// for Bluetooth, muted for a device that is not present.
|
||||
property color tone: Theme.accent
|
||||
|
||||
implicitWidth: label.implicitWidth + 14
|
||||
implicitHeight: 17
|
||||
radius: Theme.pillRadius
|
||||
color: Theme.alpha(root.tone, 0.10)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(root.tone, 0.28)
|
||||
|
||||
Text {
|
||||
id: label
|
||||
|
||||
anchors.centerIn: parent
|
||||
text: root.text
|
||||
color: root.tone
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 2)
|
||||
font.weight: Font.DemiBold
|
||||
font.capitalization: Font.AllUppercase
|
||||
font.letterSpacing: 0.5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Which applications are listening right now, and a way to shut each of them
|
||||
// up without hunting through their own preferences.
|
||||
//
|
||||
// The row is absent rather than empty when nothing is listening: "no
|
||||
// applications are using the microphone" is a sentence nobody needs to read on
|
||||
// a page they opened to change an output device.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.config
|
||||
import qs.modules.quicksettings
|
||||
import qs.services
|
||||
|
||||
SettingRow {
|
||||
id: root
|
||||
|
||||
readonly property var users: AudioDevices.captureApplications ?? []
|
||||
|
||||
label: "Using the microphone"
|
||||
detail: "Applications listening right now"
|
||||
// A Column skips invisible children, so an empty row costs no space.
|
||||
visible: root.users.length > 0
|
||||
controlWidth: Math.max(120, chips.implicitWidth + 8)
|
||||
|
||||
// Mute state is audio state: untracked nodes report false and swallow the
|
||||
// write that would have muted them.
|
||||
PwObjectTracker {
|
||||
objects: root.users.reduce((all, application) =>
|
||||
all.concat(application?.nodes ?? []), [])
|
||||
}
|
||||
|
||||
Row {
|
||||
id: chips
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
Repeater {
|
||||
model: root.users
|
||||
|
||||
Rectangle {
|
||||
id: chip
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property bool muted: AudioDevices.applicationMuted(chip.modelData)
|
||||
|
||||
implicitWidth: name.implicitWidth + muteButton.width + 20
|
||||
implicitHeight: 28
|
||||
radius: Theme.pillRadius
|
||||
color: Theme.alpha(Theme.fg, 0.06)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.08)
|
||||
|
||||
Text {
|
||||
id: name
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 11
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: String(chip.modelData?.label ?? "")
|
||||
color: chip.muted ? Theme.fgMuted : Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: muteButton
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 2
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 24
|
||||
iconSize: 14
|
||||
icon: chip.muted
|
||||
? "microphone-sensitivity-muted-symbolic"
|
||||
: "audio-input-microphone-symbolic"
|
||||
iconFallback: "audio-input-microphone-symbolic"
|
||||
tint: chip.muted ? Theme.danger : Theme.fgDim
|
||||
onClicked: AudioDevices.setApplicationMuted(chip.modelData, !chip.muted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// One chip per channel the selected output actually has.
|
||||
//
|
||||
// "Test" used to play a single front-centre sample, which answers "is this the
|
||||
// right device" and nothing else. A surround receiver wired to the wrong socket
|
||||
// passes that test. Each chip plays its own channel's sample, so the answer to
|
||||
// "is the rear left speaker actually rear left" is one click rather than a
|
||||
// guess -- and the chip lights while its sample is playing, so a silent speaker
|
||||
// is visibly a silent speaker rather than a click that did nothing.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Flow {
|
||||
id: root
|
||||
|
||||
property var node: null
|
||||
|
||||
// [{ name, label }], ordered, from the node's own channel map.
|
||||
readonly property var channels: root.node ? SoundTest.channelsFor(root.node) : []
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 10
|
||||
|
||||
// Chips share the width evenly while they fit and wrap when they do not, so
|
||||
// stereo gets two wide chips and 7.1 gets rows of whatever fits.
|
||||
readonly property int columns: Math.max(1,
|
||||
Math.min(root.channels.length, Math.floor(root.width / 150)))
|
||||
readonly property real chipWidth: root.columns > 0
|
||||
? (root.width - root.spacing * (root.columns - 1)) / root.columns
|
||||
: root.width
|
||||
|
||||
Repeater {
|
||||
model: root.channels
|
||||
|
||||
Rectangle {
|
||||
id: chip
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property string channelName: String(chip.modelData.name ?? "")
|
||||
readonly property bool playing: SoundTest.playingChannel === chip.channelName
|
||||
|
||||
width: root.chipWidth
|
||||
implicitHeight: 44
|
||||
radius: Theme.cardRadius
|
||||
color: chip.playing
|
||||
? Theme.alpha(Theme.accent, 0.20)
|
||||
: Theme.alpha(Theme.fg, chipHover.hovered ? 0.08 : 0.04)
|
||||
border.width: 1
|
||||
border.color: chip.playing
|
||||
? Theme.alpha(Theme.accent, 0.60)
|
||||
: Theme.alpha(Theme.fg, 0.09)
|
||||
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: "Play " + String(chip.modelData.label ?? "")
|
||||
|
||||
Row {
|
||||
anchors.centerIn: parent
|
||||
spacing: 8
|
||||
|
||||
ThemedIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 16
|
||||
icon: "audio-volume-high-symbolic"
|
||||
iconFallback: "audio-speakers-symbolic"
|
||||
tint: chip.playing ? Theme.fg : Theme.fgDim
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: String(chip.modelData.label ?? "")
|
||||
color: chip.playing ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: chipHover
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: SoundTest.playChannel(root.node, chip.channelName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,12 @@
|
||||
// The devices of one direction, as a list you choose from.
|
||||
//
|
||||
// The list tells the whole story, which means it also has to account for the
|
||||
// device that is not here: a session whose default is a pair of AirPods in
|
||||
// their case used to show no trace of them, so the output that audio will
|
||||
// return to the moment they connect looked like something Panama had
|
||||
// forgotten. SoundDefaults knows the configured name, and a configured name
|
||||
// with no node behind it renders last, dimmed and inert -- a ghost row.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.config
|
||||
@@ -10,8 +19,14 @@ Column {
|
||||
readonly property var nodes: AudioDevices.nodes(root.output)
|
||||
readonly property var current: AudioDevices.current(root.output)
|
||||
|
||||
// { name, label } for the configured default that is not present, or null
|
||||
// when everything the session chose is here. The service owns both the
|
||||
// comparison and the naming: an absent device has no description to borrow,
|
||||
// and its stored name is all there is to read it from.
|
||||
readonly property var absent: SoundDefaults.absent(root.output)
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 8
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: root.nodes
|
||||
@@ -26,9 +41,17 @@ Column {
|
||||
}
|
||||
}
|
||||
|
||||
SoundDeviceRow {
|
||||
width: root.width
|
||||
visible: !!root.absent
|
||||
output: root.output
|
||||
ghost: true
|
||||
ghostLabel: root.absent ? String(root.absent.label) : ""
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.nodes.length === 0
|
||||
visible: root.nodes.length === 0 && !root.absent
|
||||
text: Pipewire.ready ? "No audio devices found" : "Discovering audio devices…"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
// One audio device: what it is, how it is attached, and whether it is the one
|
||||
// in use.
|
||||
//
|
||||
// Trailing controls are measured, never guessed. The old layout subtracted the
|
||||
// Use button's width from the name column and forgot both the Test button
|
||||
// beside it and the spacing between them, so every output row overflowed its
|
||||
// card by exactly that much. The name column now ends where the trailing Row
|
||||
// begins, and that Row is as wide as whatever it happens to contain.
|
||||
//
|
||||
// Selecting is the row itself rather than a button, which is what freed the
|
||||
// width in the first place: the radio on the right says which device is in use,
|
||||
// and clicking anywhere that is not a control makes it this one.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Services.Pipewire
|
||||
@@ -6,18 +19,35 @@ import qs.widgets
|
||||
import qs.services
|
||||
import qs.modules.quicksettings
|
||||
|
||||
Rectangle {
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var node
|
||||
property var node: null
|
||||
property bool output: true
|
||||
property bool selected: false
|
||||
property bool divider: true
|
||||
|
||||
implicitHeight: root.output || !root.selected ? 88 : 105
|
||||
radius: Theme.cardRadius
|
||||
color: root.selected ? Theme.alpha(Theme.accent, 0.09) : Theme.alpha(Theme.fg, 0.025)
|
||||
border.width: 1
|
||||
border.color: root.selected ? Theme.alpha(Theme.accent, 0.34) : Theme.alpha(Theme.fg, 0.07)
|
||||
// A default that is configured but not present -- AirPods in their case.
|
||||
// It renders as itself, dimmed and inert, rather than as an absence that
|
||||
// leaves the list looking like it forgot.
|
||||
property bool ghost: false
|
||||
property string ghostLabel: ""
|
||||
|
||||
// The channel strip is per row and stays open until it is dismissed, so
|
||||
// walking a surround set is one click per speaker rather than two.
|
||||
property bool testOpen: false
|
||||
|
||||
readonly property real volume: root.node?.audio?.volume ?? 0
|
||||
readonly property bool muted: root.node?.audio?.muted ?? false
|
||||
readonly property var properties: root.node?.properties ?? ({})
|
||||
|
||||
readonly property string deviceApi: String(root.properties["device.api"] ?? "")
|
||||
readonly property bool airplay: root.deviceApi === "raop"
|
||||
readonly property bool bluetooth: root.deviceApi === "bluez5"
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
implicitHeight: content.implicitHeight
|
||||
opacity: root.ghost ? 0.55 : 1
|
||||
|
||||
PwObjectTracker {
|
||||
objects: root.node ? [root.node] : []
|
||||
@@ -29,154 +59,303 @@ Rectangle {
|
||||
enabled: !root.output && root.selected
|
||||
}
|
||||
|
||||
readonly property real volume: root.node?.audio?.volume ?? 0
|
||||
readonly property bool muted: root.node?.audio?.muted ?? false
|
||||
|
||||
function iconName(): string {
|
||||
if (!root.output)
|
||||
return root.muted ? "microphone-sensitivity-muted-symbolic" : "audio-input-microphone-symbolic";
|
||||
if (root.airplay)
|
||||
return "network-wireless-symbolic";
|
||||
if (root.bluetooth)
|
||||
return "bluetooth-active-symbolic";
|
||||
if (root.muted || root.volume <= 0.001)
|
||||
return "audio-volume-muted-symbolic";
|
||||
if (root.volume < 0.34)
|
||||
return "audio-volume-low-symbolic";
|
||||
if (root.volume < 0.67)
|
||||
return "audio-volume-medium-symbolic";
|
||||
return "audio-volume-high-symbolic";
|
||||
return "audio-speakers-symbolic";
|
||||
}
|
||||
|
||||
Row {
|
||||
id: heading
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.leftMargin: 12
|
||||
anchors.rightMargin: 10
|
||||
anchors.topMargin: 8
|
||||
height: 28
|
||||
spacing: 10
|
||||
|
||||
ThemedIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
size: 20
|
||||
icon: root.output ? "audio-speakers-symbolic" : "audio-input-microphone-symbolic"
|
||||
iconFallback: "audio-card-symbolic"
|
||||
tint: root.selected ? Theme.accent : Theme.fg
|
||||
}
|
||||
|
||||
Column {
|
||||
width: Math.max(0, parent.width - 20 - useButton.width - parent.spacing * 2)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 1
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: AudioDevices.label(root.node)
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: root.selected ? Font.DemiBold : Font.Medium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.node.nickname && root.node.nickname !== AudioDevices.label(root.node)
|
||||
text: root.node.nickname ?? ""
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
// Outputs only. Testing an input would mean recording and playing
|
||||
// it back, which is a different thing than this button implies.
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.output
|
||||
text: "Test"
|
||||
onClicked: SoundTest.play(root.node)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
id: useButton
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: root.selected ? 78 : 64
|
||||
text: root.selected ? "Default" : "Use"
|
||||
enabled: !root.selected
|
||||
onClicked: AudioDevices.select(root.output, root.node)
|
||||
}
|
||||
}
|
||||
// 48000 -> "48 kHz", 44100 -> "44.1 kHz". Absent on plenty of nodes, which
|
||||
// is why every part of the subtitle is dropped rather than defaulted.
|
||||
function rateLabel(): string {
|
||||
const rate = Number(root.properties["audio.rate"] ?? 0);
|
||||
if (!Number.isFinite(rate) || rate <= 0)
|
||||
return "";
|
||||
const khz = rate / 1000;
|
||||
return (khz % 1 === 0 ? String(khz) : khz.toFixed(1)) + " kHz";
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: muteButton
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 8
|
||||
anchors.top: heading.bottom
|
||||
anchors.topMargin: 7
|
||||
size: 30
|
||||
iconSize: 17
|
||||
icon: root.iconName()
|
||||
iconFallback: root.output ? "audio-volume-high-symbolic" : "audio-input-microphone-symbolic"
|
||||
onClicked: {
|
||||
if (root.node?.audio)
|
||||
root.node.audio.muted = !root.node.audio.muted;
|
||||
}
|
||||
// "S24_32LE" -> "24-bit", "F32LE" -> "32-bit float".
|
||||
function formatLabel(): string {
|
||||
const format = String(root.properties["audio.format"] ?? "");
|
||||
const bits = format.match(/(\d+)/);
|
||||
if (!bits)
|
||||
return "";
|
||||
return bits[1] + "-bit" + (format.startsWith("F") ? " float" : "");
|
||||
}
|
||||
|
||||
ValueSlider {
|
||||
id: volumeSlider
|
||||
anchors.left: muteButton.right
|
||||
anchors.leftMargin: 7
|
||||
anchors.right: volumeText.left
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: muteButton.verticalCenter
|
||||
value: root.muted ? 0 : root.volume
|
||||
onMoved: value => {
|
||||
if (!root.node?.audio)
|
||||
return;
|
||||
root.node.audio.muted = false;
|
||||
root.node.audio.volume = value;
|
||||
}
|
||||
function channelLabel(): string {
|
||||
const channels = root.node?.audio?.channels?.length ?? 0;
|
||||
if (channels === 1)
|
||||
return "Mono";
|
||||
if (channels === 2)
|
||||
return "Stereo";
|
||||
return channels > 2 ? channels + " channels" : "";
|
||||
}
|
||||
|
||||
Text {
|
||||
id: volumeText
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: muteButton.verticalCenter
|
||||
width: 38
|
||||
text: Math.round(root.volume * 100) + "%"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
horizontalAlignment: Text.AlignRight
|
||||
readonly property string subtitle: {
|
||||
if (root.ghost)
|
||||
return root.output
|
||||
? "Your preferred output — audio will move here when they connect"
|
||||
: "Your preferred input — Panama will listen here when it connects";
|
||||
const parts = [root.channelLabel(), root.rateLabel(), root.formatLabel()]
|
||||
.filter(part => part !== "");
|
||||
if (parts.length > 0)
|
||||
return parts.join(" · ");
|
||||
const nickname = String(root.node?.nickname ?? "");
|
||||
return nickname !== "" && nickname !== AudioDevices.label(root.node) ? nickname : "";
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: volumeSlider.left
|
||||
anchors.right: volumeText.right
|
||||
anchors.top: muteButton.bottom
|
||||
anchors.topMargin: 5
|
||||
height: 5
|
||||
radius: height / 2
|
||||
visible: !root.output && root.selected
|
||||
color: Theme.alpha(Theme.fg, 0.10)
|
||||
Column {
|
||||
id: content
|
||||
|
||||
width: parent.width
|
||||
spacing: 0
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width * Math.max(0, Math.min(1, inputPeak.peak))
|
||||
radius: parent.radius
|
||||
color: inputPeak.peak > 0.88 ? Theme.danger : Theme.accentSecondary
|
||||
id: body
|
||||
|
||||
width: parent.width
|
||||
implicitHeight: 58
|
||||
radius: Theme.cardRadius
|
||||
color: rowHover.hovered && !root.ghost
|
||||
? Theme.alpha(Theme.fg, 0.05)
|
||||
: "transparent"
|
||||
border.width: 0
|
||||
|
||||
Rectangle {
|
||||
id: iconTile
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 34
|
||||
height: 34
|
||||
radius: 10
|
||||
color: root.selected
|
||||
? Theme.alpha(Theme.accent, 0.16)
|
||||
: Theme.alpha(Theme.fg, 0.07)
|
||||
border.width: 0
|
||||
|
||||
ThemedIcon {
|
||||
anchors.centerIn: parent
|
||||
size: 18
|
||||
icon: root.iconName()
|
||||
iconFallback: root.output ? "audio-card-symbolic" : "audio-input-microphone-symbolic"
|
||||
tint: root.selected ? Theme.accent : Theme.fg
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: trailing
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 10
|
||||
|
||||
// The live level of the source being listened to. Native and
|
||||
// event-driven: it repaints when a peak arrives and never on a
|
||||
// timer, and it exists only for the selected input.
|
||||
Rectangle {
|
||||
id: meter
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: !root.output && root.selected && !root.ghost
|
||||
width: Math.min(190, Math.max(90, root.width * 0.26))
|
||||
height: 8
|
||||
radius: 4
|
||||
clip: true
|
||||
color: Theme.alpha(Theme.fg, 0.09)
|
||||
border.width: 0
|
||||
|
||||
Item {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width * Math.max(0, Math.min(1, inputPeak.peak))
|
||||
clip: true
|
||||
|
||||
// Full-width gradient behind a clipped window, so a
|
||||
// given level is always the same colour rather than a
|
||||
// squashed copy of the whole ramp.
|
||||
Rectangle {
|
||||
width: meter.width
|
||||
height: parent.height
|
||||
radius: meter.radius
|
||||
border.width: 0
|
||||
|
||||
gradient: Gradient {
|
||||
orientation: Gradient.Horizontal
|
||||
GradientStop { position: 0.0; color: Theme.ok }
|
||||
GradientStop { position: 0.82; color: Theme.warn }
|
||||
GradientStop { position: 1.0; color: Theme.danger }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mute belongs to the device in use, next to the level it
|
||||
// silences. Scrolling it nudges that device's own volume, the
|
||||
// same gesture the speaker icon carries everywhere else.
|
||||
IconButton {
|
||||
id: muteButton
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.selected && !root.ghost
|
||||
size: 30
|
||||
iconSize: 17
|
||||
icon: root.muted
|
||||
? (root.output ? "audio-volume-muted-symbolic" : "microphone-sensitivity-muted-symbolic")
|
||||
: (root.output ? "audio-volume-high-symbolic" : "audio-input-microphone-symbolic")
|
||||
iconFallback: "audio-volume-high-symbolic"
|
||||
tint: root.muted ? Theme.danger : Theme.fgDim
|
||||
onClicked: {
|
||||
if (root.node?.audio)
|
||||
root.node.audio.muted = !root.node.audio.muted;
|
||||
}
|
||||
|
||||
WheelHandler {
|
||||
onWheel: event => {
|
||||
if (!root.node?.audio)
|
||||
return;
|
||||
const step = event.angleDelta.y > 0 ? 0.05 : -0.05;
|
||||
const value = Math.max(0, Math.min(1, root.volume + step));
|
||||
root.node.audio.muted = false;
|
||||
root.node.audio.volume = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.output && root.selected && !root.ghost
|
||||
text: root.testOpen ? "Done" : "Test"
|
||||
onClicked: root.testOpen = !root.testOpen
|
||||
}
|
||||
|
||||
// Which device is in use. A radio rather than a button, because
|
||||
// choosing one is choosing all the others away.
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 18
|
||||
height: 18
|
||||
radius: 9
|
||||
color: "transparent"
|
||||
border.width: 2
|
||||
border.color: root.selected ? Theme.accent : Theme.fgMuted
|
||||
|
||||
Rectangle {
|
||||
anchors.centerIn: parent
|
||||
width: 10
|
||||
height: 10
|
||||
radius: 5
|
||||
visible: root.selected
|
||||
color: Theme.accent
|
||||
border.width: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: iconTile.right
|
||||
anchors.leftMargin: 13
|
||||
anchors.right: trailing.left
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
|
||||
Row {
|
||||
id: nameLine
|
||||
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
|
||||
// The name takes what the badges leave and elides into it,
|
||||
// so a long device name never pushes its own badge out of
|
||||
// the row.
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: Math.min(implicitWidth, Math.max(0, nameLine.width
|
||||
- (badges.width > 0 ? badges.width + nameLine.spacing : 0)))
|
||||
text: root.ghost ? root.ghostLabel : AudioDevices.label(root.node)
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: root.selected ? Font.DemiBold : Font.Medium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Row {
|
||||
id: badges
|
||||
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 6
|
||||
|
||||
SoundBadge {
|
||||
visible: root.airplay && !root.ghost
|
||||
text: "AirPlay"
|
||||
tone: Theme.cyan
|
||||
}
|
||||
|
||||
SoundBadge {
|
||||
visible: root.bluetooth && !root.ghost
|
||||
text: "Bluetooth"
|
||||
tone: Theme.accent
|
||||
}
|
||||
|
||||
SoundBadge {
|
||||
visible: root.ghost
|
||||
text: "Returns when connected"
|
||||
tone: Theme.fgMuted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.subtitle !== ""
|
||||
text: root.subtitle
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: rowHover
|
||||
enabled: !root.ghost && !root.selected
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
enabled: !root.ghost && !root.selected
|
||||
onTapped: AudioDevices.select(root.output, root.node)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: 1
|
||||
visible: root.divider
|
||||
color: Theme.alpha(Theme.fg, 0.05)
|
||||
}
|
||||
}
|
||||
|
||||
SoundChannelStrip {
|
||||
width: parent.width
|
||||
visible: root.testOpen && root.output && root.selected && !root.ghost
|
||||
node: root.node
|
||||
topPadding: 4
|
||||
bottomPadding: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +1,163 @@
|
||||
// Sound.
|
||||
//
|
||||
// One sectioned page, top to bottom: what plays, what listens, what each
|
||||
// application is doing, what the machine says when it wants attention, and the
|
||||
// card profiles underneath all of it. There are no tabs and nothing to apply --
|
||||
// every control here writes to PipeWire or to a desktop key that other
|
||||
// applications already honour, and lands the moment it is moved.
|
||||
//
|
||||
// The page used to end in a button that opened GNOME's Sound panel for device
|
||||
// profiles. It does not any more: SoundCards reads and writes them natively, so
|
||||
// there is nothing left on this subject that Panama hands to someone else.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
title: "Sound"
|
||||
lede: "Live PipeWire output, input, and device selection."
|
||||
lede: "Live PipeWire — every change lands immediately, nothing to apply."
|
||||
|
||||
readonly property var currentOutput: AudioDevices.current(true)
|
||||
readonly property var currentInput: AudioDevices.current(false)
|
||||
|
||||
// Over-amplification is the whole range, not a second slider: the maximum
|
||||
// moves and the region past 100% is marked.
|
||||
readonly property real volumeMaximum: Settings.overAmplification ? 1.5 : 1
|
||||
|
||||
// The three services that read the machine rather than the graph do it once
|
||||
// per page open, the way the Displays page refreshes hardware brightness.
|
||||
Component.onCompleted: {
|
||||
SoundFeedback.refresh();
|
||||
SoundDefaults.refresh();
|
||||
SoundCards.refresh();
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Output"
|
||||
subtitle: AudioDevices.current(true)?.description ?? "No output device"
|
||||
subtitle: root.currentOutput
|
||||
? AudioDevices.label(root.currentOutput)
|
||||
: "No output device"
|
||||
|
||||
SoundDeviceList {
|
||||
width: parent.width
|
||||
output: true
|
||||
}
|
||||
|
||||
SoundVolumeRow {
|
||||
label: "Volume"
|
||||
node: root.currentOutput
|
||||
maximum: root.volumeMaximum
|
||||
}
|
||||
|
||||
AudioBalance {
|
||||
width: parent.width
|
||||
node: AudioDevices.current(true)
|
||||
node: root.currentOutput
|
||||
}
|
||||
|
||||
ToggleRow {
|
||||
setting: "overAmplification"
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Input"
|
||||
subtitle: AudioDevices.current(false)?.description ?? "No input device"
|
||||
subtitle: root.currentInput
|
||||
? AudioDevices.label(root.currentInput)
|
||||
: "No input device"
|
||||
|
||||
SoundDeviceList {
|
||||
width: parent.width
|
||||
output: false
|
||||
}
|
||||
|
||||
SoundVolumeRow {
|
||||
label: "Input volume"
|
||||
node: root.currentInput
|
||||
}
|
||||
|
||||
// Recording and playing back is the only honest microphone test: the
|
||||
// level meter above says something is arriving, not that it is you.
|
||||
ActionRow {
|
||||
label: "Test your microphone"
|
||||
detail: "Record a few seconds and play it back through the selected output"
|
||||
divider: captureRow.visible
|
||||
action: {
|
||||
if (SoundTest.micTestState === "recording")
|
||||
return "Recording…";
|
||||
if (SoundTest.micTestState === "playing")
|
||||
return "Playing back…";
|
||||
return "Record & play back";
|
||||
}
|
||||
enabled: SoundTest.micTestState === "idle" && !!root.currentInput
|
||||
onTriggered: SoundTest.startMicTest(root.currentInput, root.currentOutput)
|
||||
}
|
||||
|
||||
SoundCaptureRow {
|
||||
id: captureRow
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Applications"
|
||||
subtitle: "Control each application currently playing through PipeWire."
|
||||
subtitle: SoundRouting.lastError !== ""
|
||||
? SoundRouting.lastError
|
||||
: "Each application's own level, and where it plays. Applications playing sound appear here on their own."
|
||||
|
||||
ApplicationMixer {
|
||||
width: parent.width
|
||||
}
|
||||
}
|
||||
|
||||
// ── What the machine says when it wants attention ───────────────────────
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Alerts & feedback"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
font.capitalization: Font.AllUppercase
|
||||
font.letterSpacing: 0.7
|
||||
topPadding: 6
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Sound feedback"
|
||||
subtitle: "Use the same event preferences as GTK and GNOME applications."
|
||||
title: "Alerts"
|
||||
subtitle: SoundFeedback.lastError
|
||||
|
||||
SoundThemeRow {}
|
||||
|
||||
SettingRow {
|
||||
label: "Event sounds"
|
||||
detail: "Play alerts and interface event sounds"
|
||||
controlWidth: 42
|
||||
detail: "Interface sounds from GTK and GNOME applications, and Panama's notification chime"
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.fill: parent
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: SoundFeedback.eventSounds
|
||||
enabled: !SoundFeedback.busy
|
||||
onToggled: checked => SoundFeedback.setEventSounds(checked)
|
||||
}
|
||||
}
|
||||
|
||||
ToggleRow {
|
||||
setting: "volumeChangeBlip"
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
label: "Input feedback"
|
||||
detail: "Play sounds for supported typing and input events"
|
||||
controlWidth: 42
|
||||
detail: "Key clicks and input event sounds in GTK applications"
|
||||
controlWidth: 48
|
||||
divider: false
|
||||
|
||||
SettingsToggle {
|
||||
anchors.fill: parent
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: SoundFeedback.inputFeedback
|
||||
enabled: !SoundFeedback.busy
|
||||
onToggled: checked => SoundFeedback.setInputFeedback(checked)
|
||||
@@ -72,15 +165,56 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "Advanced sound"
|
||||
// ── The hardware underneath the devices ─────────────────────────────────
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Advanced"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
font.capitalization: Font.AllUppercase
|
||||
font.letterSpacing: 0.7
|
||||
topPadding: 6
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Device profiles"
|
||||
detail: "Open Fedora's complete device profile panel"
|
||||
divider: false
|
||||
action: "Open panel"
|
||||
onTriggered: SystemSettings.openGnomePanel("sound")
|
||||
SettingsCard {
|
||||
title: "Device profiles"
|
||||
subtitle: SoundCards.lastError !== ""
|
||||
? SoundCards.lastError
|
||||
: "What each sound card is configured to do. Ports that are not physically connected are named, not hidden."
|
||||
|
||||
Repeater {
|
||||
model: SoundCards.cards ?? []
|
||||
|
||||
OptionPickerRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: String(modelData.description ?? modelData.name ?? "")
|
||||
detail: String(modelData.portHint ?? "")
|
||||
enabled: !SoundCards.busy
|
||||
divider: index < (SoundCards.cards?.length ?? 0) - 1
|
||||
current: modelData.activeProfile
|
||||
options: (modelData.profiles ?? []).map(profile => ({
|
||||
value: profile.name,
|
||||
label: String(profile.description ?? profile.name),
|
||||
detail: profile.available === false ? "Not available right now" : ""
|
||||
}))
|
||||
onPicked: value => SoundCards.setProfile(String(modelData.name), value)
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: (SoundCards.cards?.length ?? 0) === 0
|
||||
text: SoundCards.busy ? "Reading device profiles…" : "No sound cards reported"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
topPadding: 12
|
||||
bottomPadding: 12
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// The alert sound, chosen from the sound themes installed on the machine.
|
||||
//
|
||||
// A theme name means nothing on paper -- "freedesktop" and "Yaru" are two words
|
||||
// that sound like nothing -- so the row carries a Preview beside the value and
|
||||
// plays the theme that is set right now. It is the same shape as PickerRow,
|
||||
// which cannot take a second trailing control.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
property bool expanded: false
|
||||
property bool divider: true
|
||||
|
||||
// [{ name, directory }] -- the display name from index.theme, and the
|
||||
// directory name, which is what the gsettings key actually stores.
|
||||
readonly property var themes: SoundFeedback.themes ?? []
|
||||
readonly property string current: String(SoundFeedback.soundTheme ?? "")
|
||||
|
||||
readonly property var currentTheme:
|
||||
root.themes.find(theme => String(theme.directory ?? theme.name) === root.current) ?? null
|
||||
readonly property string currentLabel: root.currentTheme
|
||||
? String(root.currentTheme.name ?? root.currentTheme.directory)
|
||||
: root.current
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 0
|
||||
|
||||
SettingRow {
|
||||
id: headline
|
||||
|
||||
width: parent.width
|
||||
label: "Alert sound"
|
||||
detail: "Played by notifications — Panama's own included — and by applications that ask for attention"
|
||||
activatable: root.themes.length > 0
|
||||
divider: root.divider && !root.expanded
|
||||
controlWidth: Math.max(210, trailingControls.implicitWidth + 8)
|
||||
onActivated: root.expanded = !root.expanded
|
||||
|
||||
Row {
|
||||
id: trailingControls
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 9
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Preview"
|
||||
enabled: !SoundFeedback.busy
|
||||
onClicked: SoundFeedback.previewAlert()
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.currentLabel
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.themes.length > 0
|
||||
text: root.expanded ? "▴" : "▾"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: root.expanded
|
||||
|
||||
Repeater {
|
||||
model: root.themes
|
||||
|
||||
TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property string directory: String(modelData.directory ?? modelData.name)
|
||||
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? modelData.directory)
|
||||
value: directory === root.current ? "Current" : ""
|
||||
controlWidth: 90
|
||||
divider: index < root.themes.length - 1
|
||||
activatable: directory !== root.current
|
||||
onActivated: {
|
||||
SoundFeedback.setSoundTheme(directory);
|
||||
root.expanded = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// The level of the device a card is about, written straight through to
|
||||
// PipeWire -- there is nothing to apply, so there is nothing to commit and
|
||||
// nothing to debounce.
|
||||
//
|
||||
// The range is a property rather than a constant because over-amplification
|
||||
// extends it to 150%. The track keeps its full length either way, so the same
|
||||
// slider position always means the same level; the region past 100% is marked
|
||||
// in the warning tone, because that is the part that can distort.
|
||||
//
|
||||
// Not a SettingRow: the control stacks below the label in a narrow window
|
||||
// exactly as SliderRow and GradientSliderRow do, which SettingRow's fixed
|
||||
// trailing column cannot.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.config
|
||||
import qs.widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var node: null
|
||||
property string label: "Volume"
|
||||
property string detail: ""
|
||||
property bool divider: true
|
||||
property real maximum: 1
|
||||
|
||||
readonly property bool available: !!root.node?.audio
|
||||
readonly property real volume: root.node?.audio?.volume ?? 0
|
||||
readonly property bool muted: root.node?.audio?.muted ?? false
|
||||
readonly property bool amplified: root.volume > 1.001
|
||||
|
||||
readonly property bool inline: root.width >= 520
|
||||
readonly property int controlSpan: 300
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
implicitHeight: root.inline
|
||||
? Math.max(56, copy.implicitHeight + 20)
|
||||
: copy.implicitHeight + 32 + 30
|
||||
opacity: root.available ? 1 : 0.45
|
||||
|
||||
// Reading or writing audio state on an untracked node silently returns
|
||||
// zero, so the row binds the node it speaks for even though the device list
|
||||
// above it is already tracking the same object.
|
||||
PwObjectTracker {
|
||||
objects: root.node ? [root.node] : []
|
||||
}
|
||||
|
||||
function setVolume(ratio: real): void {
|
||||
if (!root.node?.audio)
|
||||
return;
|
||||
const value = Math.max(0, Math.min(root.maximum, ratio * root.maximum));
|
||||
// Moving the slider is also how you unmute, same as GNOME.
|
||||
root.node.audio.muted = false;
|
||||
root.node.audio.volume = value;
|
||||
}
|
||||
|
||||
Column {
|
||||
id: copy
|
||||
|
||||
x: 0
|
||||
y: root.inline ? (root.height - height) / 2 : 10
|
||||
width: root.inline ? root.width - root.controlSpan - 20 : root.width
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.label
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.Medium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.detail !== ""
|
||||
text: root.detail
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: control
|
||||
|
||||
width: root.inline ? root.controlSpan : root.width
|
||||
height: 32
|
||||
x: root.inline ? root.width - width : 0
|
||||
y: root.inline ? (root.height - height) / 2 : copy.y + copy.height + 10
|
||||
|
||||
ValueSlider {
|
||||
id: slider
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: readout.left
|
||||
anchors.rightMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
enabled: root.available
|
||||
value: root.muted ? 0 : root.volume / root.maximum
|
||||
onMoved: ratio => root.setVolume(ratio)
|
||||
}
|
||||
|
||||
// Everything above 100%. Drawn over the track rather than into it, so
|
||||
// the slider itself stays the one the rest of the shell uses.
|
||||
Rectangle {
|
||||
anchors.right: slider.right
|
||||
anchors.verticalCenter: slider.verticalCenter
|
||||
width: root.maximum > 1 ? slider.width * (1 - 1 / root.maximum) : 0
|
||||
height: 10
|
||||
radius: 2
|
||||
visible: root.maximum > 1
|
||||
color: Theme.alpha(Theme.warn, 0.30)
|
||||
border.width: 0
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.verticalCenter: slider.verticalCenter
|
||||
x: slider.x + slider.width / root.maximum - 1
|
||||
width: 2
|
||||
height: 16
|
||||
radius: 1
|
||||
visible: root.maximum > 1
|
||||
color: Theme.alpha(Theme.warn, 0.7)
|
||||
border.width: 0
|
||||
}
|
||||
|
||||
Text {
|
||||
id: readout
|
||||
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 46
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: Math.round(root.volume * 100) + "%"
|
||||
color: root.amplified ? Theme.warn : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.features: Theme.tabularFigures
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: 1
|
||||
visible: root.divider
|
||||
color: Theme.alpha(Theme.fg, 0.065)
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,11 @@ PasswordField 1.0 PasswordField.qml
|
||||
AudioBalance 1.0 AudioBalance.qml
|
||||
SoundDeviceList 1.0 SoundDeviceList.qml
|
||||
SoundDeviceRow 1.0 SoundDeviceRow.qml
|
||||
SoundBadge 1.0 SoundBadge.qml
|
||||
SoundChannelStrip 1.0 SoundChannelStrip.qml
|
||||
SoundVolumeRow 1.0 SoundVolumeRow.qml
|
||||
SoundThemeRow 1.0 SoundThemeRow.qml
|
||||
SoundCaptureRow 1.0 SoundCaptureRow.qml
|
||||
ApplicationMixer 1.0 ApplicationMixer.qml
|
||||
ApplicationVolumeRow 1.0 ApplicationVolumeRow.qml
|
||||
TimeOfDayRow 1.0 TimeOfDayRow.qml
|
||||
|
||||
@@ -3,6 +3,54 @@
|
||||
set -u
|
||||
|
||||
readonly PANAMA_OSD_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly PANAMA_OSD_BLIP_DEFAULT="/usr/share/sounds/freedesktop/stereo/audio-volume-change.oga"
|
||||
|
||||
# ── Panama's own preferences ────────────────────────────────────────────────
|
||||
#
|
||||
# These run as keybinds, so the shell may not be up and there is no IPC to ask.
|
||||
# settings.json is read straight off disk instead, and every failure -- no file
|
||||
# on a fresh install, a file written before the key existed, a file that is not
|
||||
# JSON at all -- means the schema default rather than a broken volume key.
|
||||
settings_file() {
|
||||
printf '%s/panama/settings.json\n' "${XDG_CONFIG_HOME:-$HOME/.config}"
|
||||
}
|
||||
|
||||
setting_bool() {
|
||||
local key="$1" fallback="$2" file value
|
||||
file="$(settings_file)"
|
||||
[[ -r $file ]] || { printf '%s\n' "$fallback"; return 0; }
|
||||
command -v jq >/dev/null 2>&1 || { printf '%s\n' "$fallback"; return 0; }
|
||||
value="$(jq -r --arg key "$key" '
|
||||
if type == "object" and (.[$key] | type) == "boolean" then .[$key] else empty end
|
||||
' "$file" 2>/dev/null)" || value=""
|
||||
[[ $value == true || $value == false ]] || value="$fallback"
|
||||
printf '%s\n' "$value"
|
||||
}
|
||||
|
||||
# wpctl clamps to 1.0 by default, and it clamps the *result* -- so the ceiling
|
||||
# has to be passed on the way down as well. Without it, stepping down from 130%
|
||||
# would snap to 100% instead of 124%, which reads as the slider jumping on its
|
||||
# own. The microphone never gets this: gain past 100% on a capture device buys
|
||||
# noise, not signal, and the Sound page's input slider stays 0-100 to match.
|
||||
volume_limit() {
|
||||
if [[ $(setting_bool overAmplification false) == true ]]; then
|
||||
printf '1.5\n'
|
||||
else
|
||||
printf '1\n'
|
||||
fi
|
||||
}
|
||||
|
||||
# The click that says the volume moved. Backgrounded and never waited on: it is
|
||||
# feedback about something that has already happened, so it must not sit between
|
||||
# the key press and the OSD. A distribution without the freedesktop sound theme
|
||||
# has no file to play, which is a silent desktop rather than a broken key.
|
||||
play_blip() {
|
||||
local sound="${PANAMA_OSD_BLIP_SOUND:-$PANAMA_OSD_BLIP_DEFAULT}"
|
||||
[[ $(setting_bool volumeChangeBlip true) == true ]] || return 0
|
||||
[[ -f $sound ]] || return 0
|
||||
pw-play "$sound" >/dev/null 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
}
|
||||
|
||||
strict_delivery() {
|
||||
[[ ${PANAMA_OSD_STRICT:-false} == true || ${PANAMA_OSD_STRICT:-false} == 1 ]]
|
||||
@@ -48,13 +96,15 @@ show_volume() {
|
||||
}
|
||||
|
||||
adjust_volume() {
|
||||
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SINK@"
|
||||
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SINK@" limit
|
||||
limit="$(volume_limit)"
|
||||
case "$action" in
|
||||
up) wpctl set-volume -l 1 "$target" "${step}%+" || return ;;
|
||||
down) wpctl set-volume "$target" "${step}%-" || return ;;
|
||||
up) wpctl set-volume -l "$limit" "$target" "${step}%+" || return ;;
|
||||
down) wpctl set-volume -l "$limit" "$target" "${step}%-" || return ;;
|
||||
toggle) wpctl set-mute "$target" toggle || return ;;
|
||||
*) printf 'Usage: panama-osd volume up|down|toggle [step]\n' >&2; return 2 ;;
|
||||
esac
|
||||
play_blip
|
||||
show_volume "$target" volume
|
||||
}
|
||||
|
||||
@@ -62,7 +112,7 @@ adjust_microphone() {
|
||||
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SOURCE@"
|
||||
case "$action" in
|
||||
up) wpctl set-volume -l 1 "$target" "${step}%+" || return ;;
|
||||
down) wpctl set-volume "$target" "${step}%-" || return ;;
|
||||
down) wpctl set-volume -l 1 "$target" "${step}%-" || return ;;
|
||||
toggle) wpctl set-mute "$target" toggle || return ;;
|
||||
*) printf 'Usage: panama-osd microphone up|down|toggle [step]\n' >&2; return 2 ;;
|
||||
esac
|
||||
|
||||
@@ -27,6 +27,16 @@ Singleton {
|
||||
readonly property var applications: AudioStreams.group(
|
||||
root.playbackStreams, PwNodeType.AudioOutStream)
|
||||
|
||||
// The capture side of the same story: which applications currently hold the
|
||||
// microphone open. Same grouping rules as playback, so an application that
|
||||
// records on three streams reads as one entry with one mute.
|
||||
readonly property var captureStreams: Pipewire.nodes.values.filter(node =>
|
||||
node.ready && node.audio
|
||||
&& (node.type & PwNodeType.AudioInStream) === PwNodeType.AudioInStream)
|
||||
|
||||
readonly property var captureApplications: AudioStreams.group(
|
||||
root.captureStreams, PwNodeType.AudioInStream)
|
||||
|
||||
function nodes(output: bool): var {
|
||||
return output ? root.outputs : root.inputs;
|
||||
}
|
||||
@@ -58,8 +68,11 @@ Singleton {
|
||||
return AudioStreams.muted(application);
|
||||
}
|
||||
|
||||
function setApplicationVolume(application: var, value: real): bool {
|
||||
return AudioStreams.setVolume(application, value);
|
||||
// `max` is the clamp ceiling; omitting it means 1, and anything that is not
|
||||
// a positive number is treated as omitted. Callers that honor the
|
||||
// over-amplification setting pass 1.5.
|
||||
function setApplicationVolume(application: var, value: real, max: real): bool {
|
||||
return AudioStreams.setVolume(application, value, max);
|
||||
}
|
||||
|
||||
function setApplicationMuted(application: var, muted: bool): bool {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,6 +15,7 @@ pragma Singleton
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Notifications
|
||||
import QtQuick
|
||||
import qs.config
|
||||
@@ -232,6 +233,7 @@ Singleton {
|
||||
// because FocusModes.allows is false whenever no mode is active.
|
||||
if (!root.doNotDisturb || FocusModes.allows(root.notificationAppId(notification))) {
|
||||
root.popups = [notification].concat(root.popups);
|
||||
root.playBell(notification);
|
||||
} else if (notification.transient) {
|
||||
// Never shown, and (being transient) never filed in history
|
||||
// either — nothing will otherwise dismiss() it, so schedule
|
||||
@@ -240,6 +242,39 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
// ── The chime ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// A notification you can hear. GTK applications get this from libcanberra
|
||||
// for free; Panama's own popups had no sound at all, so a notification that
|
||||
// arrived while you were looking elsewhere simply did not happen. Same
|
||||
// theme, same desktop preference: one switch covers the whole session
|
||||
// rather than leaving Panama as the one thing that stays quiet -- or the
|
||||
// one thing that will not shut up.
|
||||
//
|
||||
// Throttled to one bell a second. A burst -- a chat catching up after a
|
||||
// suspend, ten build jobs finishing together -- would otherwise stack a
|
||||
// dozen overlapping bells, which is a noise rather than a notification.
|
||||
property real lastBellAt: 0
|
||||
|
||||
function playBell(notification: var): void {
|
||||
if (!SoundFeedback.eventSounds)
|
||||
return;
|
||||
|
||||
// Low urgency is the "you did not need to know this" tier -- battery
|
||||
// reaching full, a sync completing. It stays silent by design.
|
||||
if (notification.urgency === NotificationUrgency.Low)
|
||||
return;
|
||||
|
||||
const now = Date.now();
|
||||
if (bell.running || now - root.lastBellAt < 1000)
|
||||
return;
|
||||
root.lastBellAt = now;
|
||||
bell.command = SoundFeedback.bellCommand;
|
||||
bell.running = true;
|
||||
}
|
||||
|
||||
Process { id: bell }
|
||||
|
||||
// Runs a DND-hidden transient notification through the same lifetime it
|
||||
// would have gotten as a visible popup (Toast.qml's countdown), just
|
||||
// without ever showing it, so it still gets released instead of staying
|
||||
|
||||
@@ -59,7 +59,8 @@ Singleton {
|
||||
"notifications": "notifications",
|
||||
"capture": "screen-intelligence",
|
||||
"gaming": "gaming",
|
||||
"search": "applications"
|
||||
"search": "applications",
|
||||
"sound": "sound"
|
||||
})
|
||||
|
||||
// Settings that are real but have no schema entry, because the system owns
|
||||
@@ -126,6 +127,13 @@ Singleton {
|
||||
{ label: "Input volume", detail: "Choose the microphone and its level", page: "sound" },
|
||||
{ label: "Per-application volume", detail: "Set the level of each application separately", page: "sound" },
|
||||
{ label: "Event sounds", detail: "Play alerts and interface event sounds", page: "sound" },
|
||||
{ label: "Balance", detail: "Shift the output between the left and right speaker", page: "sound" },
|
||||
{ label: "Speaker test", detail: "Play a tone through each channel to find which speaker is which", page: "sound" },
|
||||
{ label: "Microphone test", detail: "Record a few seconds and play it straight back", page: "sound" },
|
||||
{ label: "Alert sound", detail: "The sound theme alerts and Panama's notification chime use", page: "sound" },
|
||||
{ label: "Applications using the microphone", detail: "What is listening right now, and muting it", page: "sound" },
|
||||
{ label: "Move an application's audio", detail: "Send one application to a different output device", page: "sound" },
|
||||
{ label: "Device profiles", detail: "Switch a sound card between stereo, surround, and headset modes", page: "sound" },
|
||||
{ label: "Saved passwords", detail: "The login keyring and what is stored in it", page: "privacy" },
|
||||
{ label: "Camera and microphone", detail: "Which applications may use them", page: "privacy" },
|
||||
{ label: "Screen sharing", detail: "Which applications may capture the screen", page: "privacy" },
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
pragma Singleton
|
||||
|
||||
// Native device profiles -- the sound card's own idea of what it can be.
|
||||
//
|
||||
// A card is one piece of hardware; a profile is one wiring of it. The rear
|
||||
// Realtek can be analog stereo out, or S/PDIF out, or duplex with the line-in
|
||||
// live, but only one at a time, and switching costs the sinks and sources the
|
||||
// old profile provided. That choice is not a PipeWire node property, so
|
||||
// Quickshell's PipeWire bindings cannot see it at all -- which is why this file
|
||||
// shells out where AudioDevices must not.
|
||||
//
|
||||
// `pactl -f json list cards` is the read side (verified against pactl 17 /
|
||||
// pipewire-pulse, which prints profiles and ports as objects keyed by name, not
|
||||
// as arrays). `pactl set-card-profile` is the write side. Both talk to
|
||||
// pipewire-pulse, which owns card state -- so nothing here races the PipeWire
|
||||
// node objects AudioDevices holds.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// [{ name, description, activeProfile,
|
||||
// profiles: [{ name, description, available }], portHint }]
|
||||
property var cards: []
|
||||
|
||||
property string lastError: ""
|
||||
readonly property bool busy: lister.running || writer.running
|
||||
|
||||
// True once a listing has completed, however it went, so the UI can tell
|
||||
// "not looked yet" apart from "no cards on this machine".
|
||||
property bool loaded: false
|
||||
|
||||
// A test seam, matching scripts/panama-brightness: point this at a file
|
||||
// holding canned `pactl -f json list cards` output and the service parses
|
||||
// that instead of the live daemon.
|
||||
readonly property string fixturePath: Quickshell.env("PANAMA_SOUND_CARDS_FIXTURE") || ""
|
||||
|
||||
function refresh(): void {
|
||||
if (lister.running)
|
||||
return;
|
||||
lister.command = root.fixturePath !== ""
|
||||
? ["cat", root.fixturePath]
|
||||
: ["pactl", "-f", "json", "list", "cards"];
|
||||
lister.running = true;
|
||||
}
|
||||
|
||||
function cardFor(name: string): var {
|
||||
return root.cards.find(card => card.name === name) ?? null;
|
||||
}
|
||||
|
||||
// Switching profile is immediate and lossy: the sinks the old profile
|
||||
// provided disappear. The list is re-read afterwards rather than assumed,
|
||||
// because a card may refuse a profile its ports cannot currently support.
|
||||
function setProfile(cardName: string, profileName: string): void {
|
||||
const card = String(cardName ?? "").trim();
|
||||
const profile = String(profileName ?? "").trim();
|
||||
if (card === "" || profile === "" || writer.running)
|
||||
return;
|
||||
writer.command = ["pactl", "set-card-profile", card, profile];
|
||||
writer.running = true;
|
||||
}
|
||||
|
||||
// The most useful single line about where sound physically comes out.
|
||||
//
|
||||
// Port availability is the only part of a card's state that answers "is
|
||||
// anything plugged in", and it is per-port, so the highest-priority
|
||||
// available port is the one worth naming. A card whose ports all report
|
||||
// "availability unknown" -- USB devices, mostly -- gets no line rather than
|
||||
// a guess, because "unknown" is not "disconnected".
|
||||
function portHintFor(ports: var): string {
|
||||
let best = null;
|
||||
let anyKnown = false;
|
||||
for (const key in ports ?? {}) {
|
||||
const port = ports[key];
|
||||
const availability = String(port?.availability ?? "");
|
||||
if (availability === "available" || availability === "not available")
|
||||
anyKnown = true;
|
||||
if (availability !== "available")
|
||||
continue;
|
||||
if (!best || Number(port.priority ?? 0) > Number(best.priority ?? 0))
|
||||
best = port;
|
||||
}
|
||||
if (best)
|
||||
return `${String(best.description ?? "Port").trim()} connected`;
|
||||
return anyKnown ? "No port connected" : "";
|
||||
}
|
||||
|
||||
function parse(text: string): void {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (error) {
|
||||
root.cards = [];
|
||||
root.lastError = "Device profiles could not be read.";
|
||||
root.loaded = true;
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
root.cards = [];
|
||||
root.lastError = "Device profiles could not be read.";
|
||||
root.loaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
root.cards = parsed.map(card => {
|
||||
const properties = card.properties ?? {};
|
||||
const profiles = [];
|
||||
for (const key in card.profiles ?? {}) {
|
||||
const profile = card.profiles[key];
|
||||
profiles.push({
|
||||
name: key,
|
||||
description: String(profile?.description ?? key),
|
||||
available: profile?.available !== false,
|
||||
priority: Number(profile?.priority ?? 0)
|
||||
});
|
||||
}
|
||||
// pactl prints profiles in an object, so their order is whatever
|
||||
// the daemon happened to build. Priority is the order PulseAudio
|
||||
// and GNOME both present them in, and it puts "Off" last for free.
|
||||
profiles.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
return {
|
||||
name: String(card.name ?? ""),
|
||||
description: String(properties["device.description"]
|
||||
?? properties["device.nick"]
|
||||
?? card.name
|
||||
?? "Sound card"),
|
||||
activeProfile: String(card.active_profile ?? ""),
|
||||
profiles: profiles,
|
||||
portHint: root.portHintFor(card.ports)
|
||||
};
|
||||
}).filter(card => card.name !== "");
|
||||
|
||||
root.lastError = "";
|
||||
root.loaded = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: lister
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.parse(this.text)
|
||||
}
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0) {
|
||||
root.cards = [];
|
||||
root.lastError = "Device profiles could not be read.";
|
||||
root.loaded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: writer
|
||||
onExited: (code, status) => {
|
||||
root.lastError = code === 0 ? "" : "That device profile could not be applied.";
|
||||
root.refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
pragma Singleton
|
||||
|
||||
// What you *asked* for, as opposed to what you got.
|
||||
//
|
||||
// PipeWire keeps two different answers to "which is the default output". The
|
||||
// effective one -- the sink audio is actually reaching right now -- is what
|
||||
// Quickshell's `Pipewire.defaultAudioSink` exposes. The configured one is the
|
||||
// device you last chose, remembered by name, and it survives that device being
|
||||
// switched off, unpaired, or carried into another room.
|
||||
//
|
||||
// The two disagree constantly, and the disagreement is the whole story a sound
|
||||
// page should be telling. Choose the Bluetooth headphones, walk away, and the
|
||||
// configured default stays those headphones while the effective default falls
|
||||
// back to the speakers. A list that only renders present devices shows the
|
||||
// speakers selected and no trace of the headphones, which reads as "Panama
|
||||
// forgot" rather than "they are out of range".
|
||||
//
|
||||
// So this service reads the configured names and says, plainly, when one of
|
||||
// them maps to nothing that is currently here. The UI renders that as a ghost
|
||||
// row: visible, dimmed, not selectable, labelled "Returns when connected".
|
||||
//
|
||||
// Read through pw-metadata rather than wpctl. `wpctl status` prints the
|
||||
// *effective* defaults only -- the configured name appears nowhere in its
|
||||
// output -- so it cannot answer the question this file exists to answer.
|
||||
// `pw-metadata -n default 0` prints both, exits immediately, and mutates
|
||||
// nothing.
|
||||
//
|
||||
// Quickshell's `Pipewire.preferredDefaultAudioSink` is the same configured
|
||||
// value, but typed as a node pointer, so it reads null in exactly the case that
|
||||
// matters: the configured device is not here, and there is no node to point at.
|
||||
// A name survives where a pointer cannot, which is why the string is read
|
||||
// directly.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Pipewire
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// The names PipeWire remembers as chosen. Empty until the first read
|
||||
// lands, and empty on a session that has never had a default set by hand.
|
||||
property string configuredSinkName: ""
|
||||
property string configuredSourceName: ""
|
||||
|
||||
// The names audio is actually reaching. Kept alongside the configured pair
|
||||
// purely so the two can be compared without a second source of truth.
|
||||
property string effectiveSinkName: ""
|
||||
property string effectiveSourceName: ""
|
||||
|
||||
property string lastError: ""
|
||||
readonly property bool busy: reader.running
|
||||
|
||||
// True once a read has completed, however it went -- so the UI can tell
|
||||
// "not looked yet" apart from "looked, and nothing is configured".
|
||||
property bool loaded: false
|
||||
|
||||
// A test seam, matching scripts/panama-brightness: point this at a file
|
||||
// holding canned pw-metadata output and the service parses that instead of
|
||||
// talking to the live graph.
|
||||
readonly property string fixturePath: Quickshell.env("PANAMA_SOUND_DEFAULTS_FIXTURE") || ""
|
||||
|
||||
readonly property var presentNames: {
|
||||
const names = {};
|
||||
for (const node of Pipewire.nodes.values) {
|
||||
if (node && !node.isStream && node.name)
|
||||
names[String(node.name)] = true;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
// The configured device that is not here, or null. This is exactly what the
|
||||
// ghost row renders; `null` means every configured default is present and
|
||||
// the list has nothing extra to say.
|
||||
readonly property var absentSink: root.configuredSinkName !== ""
|
||||
&& root.presentNames[root.configuredSinkName] !== true
|
||||
? ({ name: root.configuredSinkName, label: root.label(root.configuredSinkName) })
|
||||
: null
|
||||
|
||||
readonly property var absentSource: root.configuredSourceName !== ""
|
||||
&& root.presentNames[root.configuredSourceName] !== true
|
||||
? ({ name: root.configuredSourceName, label: root.label(root.configuredSourceName) })
|
||||
: null
|
||||
|
||||
function absent(output: bool): var {
|
||||
return output ? root.absentSink : root.absentSource;
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (reader.running)
|
||||
return;
|
||||
reader.command = root.fixturePath !== ""
|
||||
? ["cat", root.fixturePath]
|
||||
: ["pw-metadata", "-n", "default", "0"];
|
||||
reader.running = true;
|
||||
}
|
||||
|
||||
// A node name turned into something a person can read.
|
||||
//
|
||||
// An absent device has no description to borrow -- descriptions live on the
|
||||
// node, and the node is gone -- so the stored name is all there is. These
|
||||
// names are structured, though, and the structure carries the useful part:
|
||||
// the Bluetooth address, the AirPlay speaker's hostname, the USB device's
|
||||
// product string. Anything this does not recognise is tidied rather than
|
||||
// guessed at, because a wrong name is worse than an ugly one.
|
||||
function label(name: string): string {
|
||||
const raw = String(name ?? "").trim();
|
||||
if (raw === "")
|
||||
return "";
|
||||
|
||||
// bluez_output.74_15_F5_13_A4_28.1 -- nothing but the address.
|
||||
const bluetooth = raw.match(/^bluez_[a-z]+\.([0-9A-Fa-f]{2}(?:_[0-9A-Fa-f]{2}){5})/);
|
||||
if (bluetooth)
|
||||
return `Bluetooth device (${bluetooth[1].replace(/_/g, ":").toUpperCase()})`;
|
||||
|
||||
// raop_sink.Living-Room.local.192.168.1.162.7000 -- the mDNS hostname.
|
||||
const airplay = raw.match(/^raop_[a-z]+\.(.+?)\.local\b/);
|
||||
if (airplay)
|
||||
return airplay[1].replace(/[-_]+/g, " ").trim();
|
||||
|
||||
// alsa_output.usb-Generic_USB_Audio-00.analog-stereo -- the udev bus id
|
||||
// carries the product string, with a two-digit interface number glued
|
||||
// on the end that means nothing to anyone.
|
||||
const usb = raw.match(/^alsa_[a-z]+\.usb-(.+?)(?:-[0-9]{2})?\.[^.]*$/);
|
||||
if (usb)
|
||||
return usb[1].replace(/_+/g, " ").replace(/\s+/g, " ").trim();
|
||||
|
||||
const tidied = raw.replace(/^[a-z0-9]+_[a-z]+\./, "")
|
||||
.replace(/[._]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return tidied || raw;
|
||||
}
|
||||
|
||||
// pw-metadata prints one line per key:
|
||||
// update: id:0 key:'default.audio.sink' value:'{"name":"..."}' type:'...'
|
||||
// The value is JSON, and carries `name` -- and, on some setups, a
|
||||
// `description` PipeWire stored alongside it, which is a better label than
|
||||
// anything derivable from the name.
|
||||
function parse(text: string): void {
|
||||
const found = {};
|
||||
for (const line of String(text ?? "").split("\n")) {
|
||||
const matched = line.match(/key:'([^']+)'\s+value:'(.*)'\s+type:'/);
|
||||
if (!matched)
|
||||
continue;
|
||||
try {
|
||||
const value = JSON.parse(matched[2]);
|
||||
const name = String(value?.name ?? "").trim();
|
||||
if (name !== "")
|
||||
found[matched[1]] = name;
|
||||
} catch (error) {
|
||||
// A key whose value is not JSON is not our key. Skip it rather
|
||||
// than failing the whole read.
|
||||
}
|
||||
}
|
||||
|
||||
root.configuredSinkName = found["default.configured.audio.sink"] ?? "";
|
||||
root.configuredSourceName = found["default.configured.audio.source"] ?? "";
|
||||
root.effectiveSinkName = found["default.audio.sink"] ?? "";
|
||||
root.effectiveSourceName = found["default.audio.source"] ?? "";
|
||||
root.loaded = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: reader
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.parse(this.text)
|
||||
}
|
||||
onExited: (code, status) => {
|
||||
root.lastError = code === 0
|
||||
? ""
|
||||
: "PipeWire's remembered default devices could not be read.";
|
||||
if (code !== 0)
|
||||
root.loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
// The effective default moving is the signal that the configured one may
|
||||
// now be unreachable (or reachable again). Re-reading on that change is
|
||||
// what keeps the ghost row honest without polling.
|
||||
Connections {
|
||||
target: Pipewire
|
||||
function onDefaultAudioSinkChanged(): void { root.refresh(); }
|
||||
function onDefaultAudioSourceChanged(): void { root.refresh(); }
|
||||
}
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
pragma Singleton
|
||||
|
||||
// GNOME and GTK applications already honor these desktop sound preferences.
|
||||
// Panama controls the same durable keys so moving between sessions does not
|
||||
// create two competing notions of whether event feedback is enabled.
|
||||
// GNOME and GTK applications already honor these desktop sound preferences --
|
||||
// whether event feedback plays, whether typing clicks, and which sound theme
|
||||
// those samples come from. Panama controls the same durable keys so moving
|
||||
// between sessions does not create two competing notions of any of it, and
|
||||
// plays its own notification chime out of the same theme (see Notifs.qml).
|
||||
//
|
||||
// There is deliberately no alert *volume* here: the desktop sound theme has no
|
||||
// volume channel of its own, and a slider that changed nothing would be worse
|
||||
// than no slider at all.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
@@ -13,9 +19,58 @@ Singleton {
|
||||
|
||||
property bool eventSounds: true
|
||||
property bool inputFeedback: false
|
||||
|
||||
// The XDG sound theme, by directory name. "freedesktop" is the one every
|
||||
// distribution ships and the fallback everything else is layered over.
|
||||
property string soundTheme: "freedesktop"
|
||||
|
||||
property string lastError: ""
|
||||
readonly property bool busy: eventRead.running || inputRead.running
|
||||
|| eventWrite.running || inputWrite.running
|
||||
|| themeRead.running || eventWrite.running || inputWrite.running
|
||||
|| themeWrite.running
|
||||
|
||||
// [{ directory, name }] -- the directory is what gsettings stores, the name
|
||||
// is what the theme calls itself in its index.theme.
|
||||
property var themes: [{ directory: "freedesktop", name: "Default" }]
|
||||
|
||||
// ── The alert sound ─────────────────────────────────────────────────────
|
||||
//
|
||||
// A theme is a directory of samples with a fallback chain, not a single
|
||||
// file, and a theme that overrides only a handful of sounds is normal. So
|
||||
// the bell is resolved as a list of candidates in preference order --
|
||||
// user-installed theme, system theme, freedesktop -- and the first one that
|
||||
// exists is played. Resolving it in the shell rather than here keeps this
|
||||
// free of file probing on a hot path.
|
||||
readonly property string homeDir: Quickshell.env("HOME") || ""
|
||||
|
||||
readonly property var bellCandidates: [
|
||||
root.homeDir !== ""
|
||||
? `${root.homeDir}/.local/share/sounds/${root.soundTheme}/stereo/bell.oga` : "",
|
||||
`/usr/share/sounds/${root.soundTheme}/stereo/bell.oga`,
|
||||
"/usr/share/sounds/freedesktop/stereo/bell.oga"
|
||||
].filter((path, index, all) => path !== "" && all.indexOf(path) === index)
|
||||
|
||||
// The argv that plays the current theme's bell once, or nothing at all if
|
||||
// no candidate exists. Shared with Notifs.qml, which plays the same bell
|
||||
// for Panama's own notification popups.
|
||||
readonly property var bellCommand: ["sh", "-c",
|
||||
'for candidate in "$@"; do [ -f "$candidate" ] && exec pw-play "$candidate"; done; exit 0',
|
||||
"qs-sound-feedback"].concat(root.bellCandidates)
|
||||
|
||||
function previewAlert(): void {
|
||||
if (preview.running)
|
||||
return;
|
||||
preview.command = root.bellCommand;
|
||||
preview.running = true;
|
||||
}
|
||||
|
||||
function setSoundTheme(name: string): void {
|
||||
const theme = String(name ?? "").trim();
|
||||
if (theme === "")
|
||||
return;
|
||||
root.soundTheme = theme;
|
||||
root._writeSoundTheme();
|
||||
}
|
||||
|
||||
function parsedBoolean(text: string, fallback: bool): bool {
|
||||
const value = text.trim();
|
||||
@@ -31,6 +86,10 @@ Singleton {
|
||||
eventRead.running = true;
|
||||
if (!inputRead.running)
|
||||
inputRead.running = true;
|
||||
if (!themeRead.running)
|
||||
themeRead.running = true;
|
||||
if (!themeScan.running)
|
||||
themeScan.running = true;
|
||||
}
|
||||
|
||||
function setEventSounds(enabled: bool): void {
|
||||
@@ -64,6 +123,20 @@ Singleton {
|
||||
inputWrite.running = true;
|
||||
}
|
||||
|
||||
function _writeSoundTheme(): void {
|
||||
if (themeWrite.running)
|
||||
return;
|
||||
themeWrite.writtenValue = root.soundTheme;
|
||||
themeWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "theme-name", root.soundTheme];
|
||||
themeWrite.running = true;
|
||||
}
|
||||
|
||||
// gsettings prints strings quoted: 'freedesktop'.
|
||||
function parsedString(text: string, fallback: string): string {
|
||||
const value = text.trim().replace(/^'(.*)'$/, "$1");
|
||||
return value === "" ? fallback : value;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: eventRead
|
||||
command: ["gsettings", "get", "org.gnome.desktop.sound", "event-sounds"]
|
||||
@@ -118,5 +191,70 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: themeRead
|
||||
command: ["gsettings", "get", "org.gnome.desktop.sound", "theme-name"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.soundTheme = root.parsedString(this.text, root.soundTheme)
|
||||
}
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0)
|
||||
root.lastError = "The alert sound theme could not be read.";
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: themeWrite
|
||||
property string writtenValue: "freedesktop"
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0) {
|
||||
root.lastError = "The alert sound theme could not be changed.";
|
||||
root.refresh();
|
||||
} else {
|
||||
root.lastError = "";
|
||||
}
|
||||
if (root.soundTheme !== themeWrite.writtenValue)
|
||||
root._writeSoundTheme();
|
||||
}
|
||||
}
|
||||
|
||||
// Installed themes, from the index.theme every XDG sound theme must carry.
|
||||
// Emitted as directory<TAB>name so the pair cannot drift apart; a theme
|
||||
// whose index.theme has no Name= falls back to its directory rather than
|
||||
// being dropped, because it is still selectable and still works.
|
||||
Process {
|
||||
id: themeScan
|
||||
command: ["sh", "-c", `
|
||||
for theme in "$HOME"/.local/share/sounds/*/index.theme /usr/share/sounds/*/index.theme; do
|
||||
[ -f "$theme" ] || continue
|
||||
directory=$(basename "$(dirname "$theme")")
|
||||
name=$(sed -n 's/^Name=//p' "$theme" | head -n1)
|
||||
[ -n "$name" ] || name=$directory
|
||||
printf '%s\\t%s\\n' "$directory" "$name"
|
||||
done
|
||||
`]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const found = [];
|
||||
const seen = {};
|
||||
for (const line of String(this.text).split("\n")) {
|
||||
const parts = line.split("\t");
|
||||
const directory = String(parts[0] ?? "").trim();
|
||||
if (directory === "" || seen[directory])
|
||||
continue;
|
||||
seen[directory] = true;
|
||||
found.push({ directory, name: String(parts[1] ?? "").trim() || directory });
|
||||
}
|
||||
// Never hand the UI an empty picker: freedesktop is the theme
|
||||
// every fallback chain ends at, present or not.
|
||||
if (!seen["freedesktop"])
|
||||
found.push({ directory: "freedesktop", name: "Default" });
|
||||
root.themes = found;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process { id: preview }
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
pragma Singleton
|
||||
|
||||
// Sending one application's audio to a different output.
|
||||
//
|
||||
// PipeWire routes a playback stream either by following the system default or
|
||||
// by a per-stream target the session manager remembers. Quickshell's PipeWire
|
||||
// bindings expose the graph but not that routing decision, so moving a stream
|
||||
// means shelling out -- which is why this lives beside AudioDevices rather than
|
||||
// inside it, on the same reasoning as SoundTest.
|
||||
//
|
||||
// ── Reading: native ─────────────────────────────────────────────────────────
|
||||
// `currentSinkFor` walks `Pipewire.linkGroups`, which already says which sink a
|
||||
// stream's links land on. Nothing is shelled out to read.
|
||||
//
|
||||
// It reports "" -- follows the default -- whenever the stream is linked to the
|
||||
// sink that is currently the default. That is a deliberate simplification: an
|
||||
// application explicitly pinned to the device that also happens to be the
|
||||
// default is indistinguishable from one that is merely following it, and the
|
||||
// difference is inaudible for as long as it lasts. The moment the default moves
|
||||
// away, the pinned stream stays put and starts reporting its real sink, which
|
||||
// is the case the picker actually needs to get right. The alternative --
|
||||
// polling `pw-metadata` for per-node target keys -- buys a distinction nobody
|
||||
// can hear, at the cost of a timer.
|
||||
//
|
||||
// ── Writing: two different mechanisms, deliberately ─────────────────────────
|
||||
// `moveApplication` uses `pactl move-sink-input <serial> <sink>`. The serial is
|
||||
// `object.serial`, which is exactly what pipewire-pulse presents as a PulseAudio
|
||||
// index (verified: `pactl -f json list` prints `index` equal to the object's
|
||||
// `object.serial`), so no id translation is needed.
|
||||
//
|
||||
// `routeToDefault` cannot use the same call, because moving a stream *to* the
|
||||
// default sink pins it there -- it would stop following, and quietly stay behind
|
||||
// the next time the default changed. Pinning is stored as a `target.object` key
|
||||
// on the stream's node id in PipeWire's "default" metadata, so releasing it is
|
||||
// deleting that key: `pw-metadata -n default -d <node-id> target.object`. The
|
||||
// session manager sees the metadata change and re-links the stream to whatever
|
||||
// the default is now. The legacy `target.node` key is deleted alongside it,
|
||||
// because streams pinned by older tooling carry that one instead.
|
||||
//
|
||||
// Note the id asymmetry: pactl speaks object serials, pw-metadata speaks node
|
||||
// ids. They are different numbers for the same stream.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Pipewire
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property string lastError: ""
|
||||
readonly property bool busy: mover.running || root.queue.length > 0
|
||||
|
||||
// Pending commands, run one at a time. An application on three streams is
|
||||
// three calls, and letting them overlap means three processes racing for
|
||||
// the same metadata.
|
||||
property var queue: []
|
||||
|
||||
function streamSerial(node: var): string {
|
||||
const serial = String(node?.properties?.["object.serial"] ?? "").trim();
|
||||
return serial !== "" ? serial : String(node?.id ?? "");
|
||||
}
|
||||
|
||||
// Name of the sink this application's audio is reaching, or "" when it is
|
||||
// simply following the system default. See the note above on why those two
|
||||
// answers merge while the default *is* that sink.
|
||||
function currentSinkFor(group: var): string {
|
||||
const nodes = group?.nodes ?? [];
|
||||
if (nodes.length === 0)
|
||||
return "";
|
||||
|
||||
const stream = nodes[0];
|
||||
const defaultSink = Pipewire.defaultAudioSink;
|
||||
for (const linkGroup of Pipewire.linkGroups.values) {
|
||||
if (linkGroup?.source !== stream || !linkGroup?.target)
|
||||
continue;
|
||||
if (defaultSink && linkGroup.target === defaultSink)
|
||||
return "";
|
||||
return String(linkGroup.target.name ?? "");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Pin every stream of this application to one sink.
|
||||
function moveApplication(group: var, sinkName: string): void {
|
||||
const sink = String(sinkName ?? "").trim();
|
||||
const nodes = group?.nodes ?? [];
|
||||
if (sink === "" || nodes.length === 0)
|
||||
return;
|
||||
|
||||
const commands = [];
|
||||
for (const node of nodes) {
|
||||
const serial = root.streamSerial(node);
|
||||
if (serial !== "")
|
||||
commands.push(["pactl", "move-sink-input", serial, sink]);
|
||||
}
|
||||
root.enqueue(commands, "That application's audio could not be moved.");
|
||||
}
|
||||
|
||||
// Release the pin, so the application follows the system default again.
|
||||
//
|
||||
// Mechanism: delete the stream's routing key from PipeWire's "default"
|
||||
// metadata -- `pw-metadata -n default -d <node-id> target.object`, plus the
|
||||
// legacy `target.node` for streams older tooling pinned. The session
|
||||
// manager re-links on the metadata change.
|
||||
//
|
||||
// Deliberately NOT `pactl move-sink-input <serial> <default sink>`: that
|
||||
// writes the pin rather than clearing it, so the application would sit on
|
||||
// today's default forever and quietly stop following tomorrow's.
|
||||
//
|
||||
// Note the id asymmetry -- pw-metadata takes the node id, pactl takes the
|
||||
// object serial, and they are different numbers for the same stream.
|
||||
function routeToDefault(group: var): void {
|
||||
const nodes = group?.nodes ?? [];
|
||||
if (nodes.length === 0)
|
||||
return;
|
||||
|
||||
const commands = [];
|
||||
for (const node of nodes) {
|
||||
const id = String(node?.id ?? "").trim();
|
||||
if (id === "")
|
||||
continue;
|
||||
commands.push(["pw-metadata", "-n", "default", "-d", id, "target.object"]);
|
||||
commands.push(["pw-metadata", "-n", "default", "-d", id, "target.node"]);
|
||||
}
|
||||
root.enqueue(commands, "That application could not be returned to the default output.");
|
||||
}
|
||||
|
||||
function enqueue(commands: var, failureMessage: string): void {
|
||||
if (commands.length === 0)
|
||||
return;
|
||||
root.lastError = "";
|
||||
root.queue = root.queue.concat(commands.map(command => ({
|
||||
command: command,
|
||||
failureMessage: failureMessage
|
||||
})));
|
||||
root.pump();
|
||||
}
|
||||
|
||||
property string pendingFailure: ""
|
||||
|
||||
function pump(): void {
|
||||
if (mover.running || root.queue.length === 0)
|
||||
return;
|
||||
const next = root.queue[0];
|
||||
root.queue = root.queue.slice(1);
|
||||
root.pendingFailure = next.failureMessage;
|
||||
mover.command = next.command;
|
||||
mover.running = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: mover
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0)
|
||||
root.lastError = root.pendingFailure;
|
||||
root.pump();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// Read-only contract harness for the Sound page. It instantiates every device
|
||||
// row against the real PipeWire graph but exposes no mutating IPC methods.
|
||||
//
|
||||
// The two services that read the machine rather than the graph -- SoundDefaults
|
||||
// and SoundCards -- are fed through their own fixture seams by the contract, so
|
||||
// the ghost row and the profile card can be measured against canned state
|
||||
// instead of against whatever hardware is plugged in today.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
@@ -34,6 +39,20 @@ ShellRoot {
|
||||
height: 900
|
||||
}
|
||||
|
||||
// A device list of its own, so the ghost row can be read without reaching
|
||||
// into the page's internals.
|
||||
SoundDeviceList {
|
||||
id: outputList
|
||||
width: 620
|
||||
visible: false
|
||||
output: true
|
||||
}
|
||||
|
||||
SoundCaptureRow {
|
||||
id: captureProbe
|
||||
width: 620
|
||||
}
|
||||
|
||||
ApplicationMixer {
|
||||
id: populatedMixer
|
||||
width: 620
|
||||
@@ -78,7 +97,15 @@ ShellRoot {
|
||||
emptyRows: emptyMixer.rowCount,
|
||||
emptyStatus: emptyMixer.statusText,
|
||||
unavailableRows: unavailableMixer.rowCount,
|
||||
unavailableStatus: unavailableMixer.statusText
|
||||
unavailableStatus: unavailableMixer.statusText,
|
||||
captureApplications: AudioDevices.captureApplications.length,
|
||||
captureTypesValid: AudioDevices.captureApplications.every(application =>
|
||||
application.nodes.every(node =>
|
||||
(node.type & PwNodeType.AudioInStream)
|
||||
=== PwNodeType.AudioInStream)),
|
||||
captureRowVisible: captureProbe.visible,
|
||||
ghostVisible: !!outputList.absent,
|
||||
ghostLabel: outputList.absent ? String(outputList.absent.label) : ""
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// Fixture-fed harness for the three sound services that shell out:
|
||||
// SoundCards (pactl list cards / set-card-profile), SoundRouting (pactl
|
||||
// move-sink-input) and SoundDefaults (pw-metadata -n default).
|
||||
//
|
||||
// Nothing here touches the real audio graph. The contracts that drive this file
|
||||
// replace pactl and pw-metadata with recording stubs, so what is under test is
|
||||
// the parsing and the argv -- not PipeWire, which has its own opinions and a
|
||||
// different set on every machine.
|
||||
//
|
||||
// Application groups arrive as plain objects with the same shape AudioStreams.js
|
||||
// produces, because that is what the services are handed at runtime and it is
|
||||
// the part a refactor is most likely to break silently.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
id: root
|
||||
|
||||
// Two streams of one application, the way PipeWire numbers them: an
|
||||
// `object.serial` that pactl accepts, distinct from the node id.
|
||||
readonly property var twoStreamGroup: ({
|
||||
key: "org.chromium.Chromium",
|
||||
label: "Chromium",
|
||||
icon: "chromium",
|
||||
nodes: [
|
||||
{ id: 61, properties: { "object.serial": "412" } },
|
||||
{ id: 62, properties: { "object.serial": "418" } }
|
||||
]
|
||||
})
|
||||
|
||||
// A stream PipeWire never gave a serial. The node id is the fallback, and
|
||||
// it has to be one: an application that cannot be moved is worse than one
|
||||
// moved by a less stable handle.
|
||||
readonly property var seriallessGroup: ({
|
||||
key: "mpv",
|
||||
label: "mpv",
|
||||
icon: "mpv",
|
||||
nodes: [{ id: 77, properties: {} }]
|
||||
})
|
||||
|
||||
readonly property var emptyGroup: ({
|
||||
key: "gone",
|
||||
label: "Gone",
|
||||
icon: "",
|
||||
nodes: []
|
||||
})
|
||||
|
||||
function groupNamed(name: string): var {
|
||||
if (name === "twoStream")
|
||||
return root.twoStreamGroup;
|
||||
if (name === "serialless")
|
||||
return root.seriallessGroup;
|
||||
return root.emptyGroup;
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "sound-services-test"
|
||||
|
||||
// ── SoundCards ──────────────────────────────────────────────────────
|
||||
function cards(): string {
|
||||
return JSON.stringify({
|
||||
busy: SoundCards.busy,
|
||||
lastError: SoundCards.lastError,
|
||||
cards: SoundCards.cards
|
||||
});
|
||||
}
|
||||
|
||||
function refreshCards(): bool {
|
||||
SoundCards.refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
function setProfile(card: string, profile: string): bool {
|
||||
return SoundCards.setProfile(card, profile) !== false;
|
||||
}
|
||||
|
||||
// ── SoundRouting ────────────────────────────────────────────────────
|
||||
function routing(): string {
|
||||
return JSON.stringify({
|
||||
busy: SoundRouting.busy,
|
||||
lastError: SoundRouting.lastError
|
||||
});
|
||||
}
|
||||
|
||||
function moveApplication(group: string, sink: string): bool {
|
||||
return SoundRouting.moveApplication(root.groupNamed(group), sink) !== false;
|
||||
}
|
||||
|
||||
function routeToDefault(group: string): bool {
|
||||
return SoundRouting.routeToDefault(root.groupNamed(group)) !== false;
|
||||
}
|
||||
|
||||
function currentSinkFor(group: string): string {
|
||||
return String(SoundRouting.currentSinkFor(root.groupNamed(group)) ?? "");
|
||||
}
|
||||
|
||||
// ── SoundDefaults ───────────────────────────────────────────────────
|
||||
function defaults(): string {
|
||||
return JSON.stringify({
|
||||
sink: SoundDefaults.configuredSinkName,
|
||||
source: SoundDefaults.configuredSourceName
|
||||
});
|
||||
}
|
||||
|
||||
function refreshDefaults(): bool {
|
||||
SoundDefaults.refresh();
|
||||
return true;
|
||||
}
|
||||
|
||||
// What the ghost row would render, and what it would call the device.
|
||||
// The name is all there is to go on: the node is gone, so there is no
|
||||
// description to borrow.
|
||||
function labelFor(name: string): string {
|
||||
return SoundDefaults.label(name);
|
||||
}
|
||||
|
||||
function absent(output: string): string {
|
||||
const record = SoundDefaults.absent(output === "output");
|
||||
return JSON.stringify(record === null ? { present: true } : {
|
||||
present: false,
|
||||
name: record.name,
|
||||
label: record.label
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,6 @@
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Open Sound in Settings.
|
||||
# @vicinae.keywords ["settings", "output volume", "input volume", "per-application volume", "event sounds"]
|
||||
# @vicinae.keywords ["settings", "over-amplification", "volume-change blip", "output volume", "input volume", "per-application volume", "event sounds", "balance", "speaker test", "microphone test", "alert sound", "applications using the microphone", "move an application's audio"]
|
||||
|
||||
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page sound
|
||||
|
||||
+10
-1
@@ -4,7 +4,7 @@
|
||||
Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
|
||||
after changing the schema; a contract fails when this copy is stale.
|
||||
|
||||
162 settings across 34 groups. 70 of them are applied to the compositor and confirmed by reading the value back.
|
||||
164 settings across 35 groups. 70 of them are applied to the compositor and confirmed by reading the value back.
|
||||
|
||||
## accessibility
|
||||
|
||||
@@ -291,6 +291,15 @@ Found on **Applications › Applications**.
|
||||
|---|---|---|
|
||||
| **Web search engine**<br>`webSearchUrl` | https://duckduckgo.com/?q= | Where the launcher's web search sends a query; the search text is appended |
|
||||
|
||||
## sound
|
||||
|
||||
Found on **Sound**.
|
||||
|
||||
| Setting | Default | What it does |
|
||||
|---|---|---|
|
||||
| **Over-amplification**<br>`overAmplification` | false | Lets the volume slider go to 150% — louder, at the cost of distortion on some hardware |
|
||||
| **Volume-change blip**<br>`volumeChangeBlip` | true | A short click each time the volume keys move the output level |
|
||||
|
||||
## themes
|
||||
|
||||
Found on **Appearance**.
|
||||
|
||||
@@ -7,7 +7,7 @@ with Gabriel's go-ahead**, and failures get fixed then.
|
||||
|
||||
## The run
|
||||
|
||||
- `panama test` — the full suite (**166** contracts as of the phase 4 Shell
|
||||
- `panama test` — the full suite (**169** contracts as of the phase 6 Sound
|
||||
wave; the top-level README's count line is set to match and is itself
|
||||
checked by `setup/readme-contract`).
|
||||
|
||||
@@ -249,3 +249,99 @@ re-checked against their files as those landed:
|
||||
then `display-arrangement-contract`, and `displays-contract` last — it is the
|
||||
only one that drives the physical display, and it refuses to start from a
|
||||
scale that does not match what `monitors.lua` ships.
|
||||
|
||||
## Phase 6 (Sound) — append below
|
||||
|
||||
Spec: `2026-08-24-sound-redesign.md`. The Sound page became a complete PipeWire
|
||||
surface: honest device list with badges and a ghost row, per-channel speaker
|
||||
test, microphone test, per-app mic mute and per-app output routing, alert-sound
|
||||
theme, over-amplification, and native device profiles in place of the GNOME
|
||||
handoff.
|
||||
|
||||
**Nothing in this wave was run.** Three agents were editing the tree
|
||||
concurrently and every harness here drives the live audio graph — the page
|
||||
harness constructs the real Sound page against the session's own PipeWire, and
|
||||
the services harness starts a shell. What *was* verified is listed as static
|
||||
below: bash syntax on every contract, the JSON and pw-metadata fixtures parsed,
|
||||
and each service's own parsing logic replayed in node or Python against the
|
||||
landed source so the expected values in the assertions are the values the code
|
||||
actually produces.
|
||||
|
||||
### New contracts (3)
|
||||
|
||||
| Contract | What it pins | Verified |
|
||||
|---|---|---|
|
||||
| `quickshell/sound-cards-contract` | `SoundCards`' parse of `pactl -f json list cards`: profiles keyed by name becoming an ordered list sorted by pactl priority (the fixture deliberately lists them out of order, so insertion order fails), a profile pactl marked unavailable kept and flagged rather than dropped, `device.description` as the card name, the port hint naming the connected port and reading exactly `No port connected` when none is, `set-card-profile` argv, an empty card or profile name starting no write, unparseable output and a failing read both degrading into `lastError` with an array still in `cards`, and recovery on the next refresh. Plus the static grep on the live `-f json` command, which the fixture seam means nothing else exercises. | **Statically verified**: `SoundCards.parse` and `portHintFor` replayed in Python against the fixture — profile order, availability flags, descriptions and both port hints match the assertions exactly. Every IPC call is **deferred**. |
|
||||
| `quickshell/sound-routing-contract` | `SoundRouting`: every stream of a group moved, by `object.serial` and not by node id (both are plausible numbers in a log); a serial-less stream falling back to its node id; an empty group or unnamed sink running nothing; and `routeToDefault` releasing the pin through `pw-metadata -n default -d <node-id> target.object` **and** the legacy `target.node`, never through `move-sink-input` — moving a stream to the current default pins it there, which is the bug the button undoes. Plus the id asymmetry both ways, `busy`/`lastError` on refusal, recovery, and the spec-required comment above `routeToDefault`. | **Statically verified** against the landed service: the argv shapes, the serial fallback, the two metadata keys and the early returns all read directly off `SoundRouting.qml`. The runs are **deferred**. |
|
||||
| `quickshell/sound-defaults-contract` | The one distinction the ghost row depends on: `default.configured.audio.sink` and not `default.audio.sink`. The fixture sets them to different values and writes the configured one first, so neither "last key wins" nor a machine whose configured device is present can make it pass by accident. Also: an unconfigured session reading as `""` rather than as the effective device, an empty store, a non-JSON value on an unrelated key not taking the read down with it, a failed read leaving nothing invented, recovery, the ghost record and its label (Bluetooth address, AirPlay hostname, USB product string, empty), and a static ban on reading `preferredDefaultAudioSink`, which is null in exactly the case the service exists for. | **Statically verified**: `SoundDefaults.parse` and `label` evaluated in node against all five fixtures — every expected string in the contract came from that run. The IPC half is **deferred**. |
|
||||
|
||||
### Updated contracts (4)
|
||||
|
||||
| Contract | What it now pins | Verified |
|
||||
|---|---|---|
|
||||
| `quickshell/sound-page-contract` | Rebuilt around the new page. Kept: the two `SoundDeviceList`s, the Dictation negatives and handoff, the balance and device-row pins, the Quick Settings sharing. Dropped: `openGnomePanel("sound")` and `label: "Device profiles"` — both now asserted **absent**, replaced by the native profile card (`title:`, `SoundCards.cards`, `setProfile(`, `refresh()`, `lastError`). Added: `captureApplications` filtered by `AudioInStream` and reaching `SoundCaptureRow` with a per-app mute, a tracked `PwObjectTracker` over capture nodes, the row hiding itself when nothing is listening; the ghost row asked of `SoundDefaults.absent(root.output)`, rendering after the `Repeater`, and non-interactive by a brace-scan proving every `TapHandler`/`HoverHandler` carries `!root.ghost`; the `device.api` badges; over-amplification gated at 1.5 on the page and on Quick Settings' **output** slider only, with the >100% region marked; the four `SoundRouting` calls in `ApplicationVolumeRow`. The no-shell-out ban is unchanged on the four core files and now extended over all eleven page components. | **The whole static half was run** against the landed tree and passes. The harness half is **deferred**. |
|
||||
| `quickshell/application-volume-contract` | Grouping pins unchanged; new `clampVolume` case for the `max` parameter — `setVolume(group, 1.4, 1.5)` lands 1.4 and unmutes, 2.5 clamps to 1.5, **no** max clamps to 1 (the default must not quietly follow the preference), a negative clamps to 0, and a non-numeric value changes nothing and returns false. | **Statically verified**: the landed `AudioStreams.js` evaluated in node produced byte-identical output to the `jq` filter's expectations. The IPC run is **deferred**. |
|
||||
| `quickshell/osd-helper-contract` | Over-amplification and the blip, both out of `settings.json`. Every run now gets its own `XDG_CONFIG_HOME`, because the helper would otherwise read Gabriel's real preferences and pass or fail on which switches he has on. Pins `-l 1.5` on volume up **and down** (wpctl clamps the result, so coming down from 130% would snap to 100% without it), `-l 1` on the microphone in the same run, `-l 1` for off / key absent / file absent / malformed JSON, the blip on up/down/toggle and not on brightness or microphone, silence with `volumeChangeBlip` false, and silence with no sound file — with the OSD still shown in every degraded case. | **Bash syntax only.** The helper is agent A's and had not landed when this was written; see the open item below. |
|
||||
|
||||
### Cross-agent shapes these contracts pin
|
||||
|
||||
Written from the spec's pinned API while agents A and B worked in parallel, then
|
||||
re-checked against their files as those landed:
|
||||
|
||||
- `PANAMA_SOUND_CARDS_FIXTURE` and `PANAMA_SOUND_DEFAULTS_FIXTURE` are **file
|
||||
paths** the service `cat`s in place of the live command, read once at
|
||||
singleton construction. So a contract changes what the file *says* between
|
||||
cases rather than where it points, and deletes it to make a read fail.
|
||||
Confirmed against both landed services.
|
||||
- `sound-services-harness.qml` is new and shared by all three service
|
||||
contracts. Whichever contract is running sets an inert fixture for the two
|
||||
services it is not testing, so nothing reaches the live daemon and every line
|
||||
in a command log belongs to the service under test.
|
||||
- `SoundDeviceList` asks `SoundDefaults.absent(output)` rather than comparing
|
||||
configured names itself — the service owns both the comparison and the label.
|
||||
The contract followed B's refactor to that shape.
|
||||
- The ghost label is A's `SoundDefaults.label()`, so it reads
|
||||
`Bluetooth device (AA:BB:CC:11:22:33)` **with** parentheses. `SoundDeviceList`
|
||||
briefly had its own `tidyName()` producing the same string without them; the
|
||||
harness and the assertion track the service's version.
|
||||
|
||||
### Docs updated in the same wave
|
||||
|
||||
- `services/SettingsSearch.qml` — the seven hand-written Sound entries from the
|
||||
spec (Balance, Speaker test, Microphone test, Alert sound, Applications using
|
||||
the microphone, Move an application's audio, Device profiles) on top of the
|
||||
four that were already there, and `groupPages` gained `"sound": "sound"` so
|
||||
the new schema group self-indexes.
|
||||
- Top-level `README.md` — contract count 166 → 169, recounted with the same
|
||||
`find` `setup/readme-contract` uses; that contract was run and passes.
|
||||
|
||||
### Still open before the run
|
||||
|
||||
- **`osd-helper-contract` is written against a `panama-osd` that had not
|
||||
landed.** It assumes two things of agent A's helper: that the settings file
|
||||
is resolved as `${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json` (the
|
||||
`panama-idle` / `panama-lid` spelling, not `panama-palette`'s
|
||||
`PANAMA_SETTINGS` override), and that the blip's sound file can be overridden
|
||||
with `PANAMA_OSD_BLIP_SOUND` (matching the existing `PANAMA_OSD_*` seams in
|
||||
the same script), which is the only way to exercise the missing-file branch
|
||||
deterministically. If A spelled either differently, the env names in
|
||||
`run_helper` are the only lines that need changing. Reconcile before running.
|
||||
- The contract also now asserts `-l` on the **down** step, which the
|
||||
pre-redesign helper did not pass. The reasoning is in the contract; if the
|
||||
landed helper only limits the up step, that is a real bug at 150% and not a
|
||||
contract to relax.
|
||||
- `sound-page-contract`'s runtime half asserts `.captureApplications >= 0` and
|
||||
`.captureRowVisible == (.captureApplications > 0)` — true on a machine where
|
||||
nothing is recording, which is the ordinary case. Getting a positive capture
|
||||
count under test would mean holding the microphone open from the contract;
|
||||
the grouping itself is covered by `captureTypesValid` and by the static greps.
|
||||
- No contract in this wave has had its harness started. Run order for the
|
||||
sweep: `sound-defaults-contract`, `sound-cards-contract`,
|
||||
`sound-routing-contract` (all three fixture-fed and cheap), then
|
||||
`application-volume-contract`, then `osd-helper-contract`, and
|
||||
`sound-page-contract` last — it is the only one that constructs the real page
|
||||
against the session's own audio graph.
|
||||
- Nothing in this wave plays a sound on purpose, but `sound-page-contract`
|
||||
constructs `SoundPage`, whose microphone test and channel strip are one
|
||||
IPC-less click away from `pw-play`. The harness exposes no method that
|
||||
triggers either; keep it that way.
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# Sound redesign — one page, whole story
|
||||
|
||||
Approved mock: `home-mocks/sound.html` (scratchpad, :8642). This spec is the implementation
|
||||
contract; where the mock and this file disagree, this file wins. The page stays a single sectioned
|
||||
scroll (no tabs). Everything is live PipeWire — nothing to apply or confirm.
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Fix the Test/Use overflow** — `SoundDeviceRow.qml`'s heading Column subtracts `useButton.width`
|
||||
but not the Test button's width nor the inner Row's spacing, so output rows overflow the card by
|
||||
`testWidth + 7` px. The rebuilt row layout must measure both buttons (no hardcoded 78/64 widths;
|
||||
let implicitWidth speak).
|
||||
2. **Honest device list** — badges and ghost rows so the list tells the whole story.
|
||||
3. **Complete the page** — per-channel test, mic test, mic-using apps, per-app routing, alert
|
||||
sounds Panama's own notifications obey, over-amplification, native device profiles.
|
||||
|
||||
Non-goals: alert-sound *volume* (no real channel; deliberately dropped), mono output, sample-rate
|
||||
switching, EasyEffects/noise suppression, input-side balance.
|
||||
|
||||
## Constraints that stand
|
||||
|
||||
- `sound-page-contract` bans `Process|pactl|wpctl` in **AudioDevices / SoundDeviceList /
|
||||
SoundDeviceRow / AudioBalance**. Anything that shells out lives in a sibling singleton service
|
||||
(the SoundTest/SoundFeedback precedent). Update the contract only where the spec says so.
|
||||
- Quick Settings keeps sharing `AudioDevices` (outputs/inputs/select).
|
||||
- No continuously repainting animations. The input level meter uses `PwNodePeakMonitor` (native,
|
||||
event-driven); it renders only while the Input section is on screen and the device is selected.
|
||||
|
||||
## Service API (pinned — UI programs against this)
|
||||
|
||||
**`AudioDevices.qml`** (native-only, extended):
|
||||
- `captureApplications` — mic-using apps: `AudioStreams.group(captureStreams, PwNodeType.AudioInStream)`
|
||||
where `captureStreams` filters `ready && audio && (type & PwNodeType.AudioInStream)`.
|
||||
- `applicationVolume/Muted` + setters already exist and work for both groups (AudioStreams.js is
|
||||
group-shape agnostic).
|
||||
- Device badges are UI-side, native: `node.properties["device.api"]` — `"raop"` → AirPlay badge,
|
||||
`"bluez5"` → Bluetooth badge. Subtitle rate/format from `node.properties["audio.rate"]` /
|
||||
format keys when present; omit gracefully when absent.
|
||||
|
||||
**`SoundDefaults.qml`** (new singleton, shells out): the configured-vs-effective story.
|
||||
- `configuredSinkName` / `configuredSourceName` — from `pw-metadata -n default 0` (or `pw-dump`
|
||||
fallback; agent picks the stable one), refreshed on `Pipewire.defaultAudioSink/Source` changes
|
||||
and on a page-open `refresh()`.
|
||||
- The ghost row renders when the configured name maps to **no present node**; label from the
|
||||
stored name (e.g. `bluez_output.74_15_F5_13_A4_28.1` → cleaned to the stored description when
|
||||
pw-metadata carries one, else a tidied name). Ghost rows are non-interactive; badge text
|
||||
"Returns when connected".
|
||||
|
||||
**`SoundCards.qml`** (new singleton, shells out): native device profiles.
|
||||
- `cards: [{ name, description, activeProfile, profiles: [{ name, description, available }],
|
||||
portHint }]` from `pactl -f json list cards` (portHint: best human line from port availability,
|
||||
e.g. "Line out connected" / "No port connected").
|
||||
- `setProfile(cardName, profileName)` → `pactl set-card-profile`; `busy`, `lastError`,
|
||||
`refresh()`. Refresh on page open (the Displays `Brightness.refresh()` pattern).
|
||||
|
||||
**`SoundRouting.qml`** (new singleton, shells out): per-app output routing.
|
||||
- `moveApplication(group, sinkName)` — `pactl move-sink-input <serial> <sink>` for every stream
|
||||
in the group (stream serial from `node.properties["object.serial"]`, fallback `node.id`).
|
||||
- `routeToDefault(group)` — return the app to following the system default (agent determines the
|
||||
reliable mechanism: `pw-metadata` target clear, or move to the current default sink — document
|
||||
which in a comment).
|
||||
- `currentSinkFor(group)` — name of the sink the group's first stream is linked to, `""` when it
|
||||
follows the default. Derived natively where possible (`Pipewire.linkGroups` is readable) —
|
||||
shells out only if link groups prove insufficient.
|
||||
- `busy`, `lastError`.
|
||||
|
||||
**`SoundTest.qml`** (extended, keeps its no-race separation):
|
||||
- `playChannel(node, channelName)` — per-channel freedesktop samples
|
||||
(`audio-channel-front-left.oga`, `-front-right`, `-front-center`, `-rear-left`, `-rear-right`,
|
||||
`-side-left`, `-side-right` as they exist on disk), `pw-play --target <node.name>`.
|
||||
- `channelsFor(node)` — ordered `[{ name, label }]` from `node.audio.channels` via
|
||||
`PwAudioChannel`; stereo → Front Left / Front Right.
|
||||
- `playingChannel` — for the lit chip.
|
||||
- Mic test: `startMicTest(sourceNode, sinkNode)` → `pw-record` ~3 s to a file under
|
||||
`$XDG_RUNTIME_DIR`, then `pw-play` it back; `micTestState`: `"idle" | "recording" | "playing"`;
|
||||
`cancelMicTest()`. The temp file is overwritten each run, never accumulated.
|
||||
|
||||
**`SoundFeedback.qml`** (extended):
|
||||
- Existing `eventSounds` / `inputFeedback` unchanged.
|
||||
- `soundTheme` / `setSoundTheme(name)` — gsettings `org.gnome.desktop.sound theme-name`.
|
||||
- `themes` — installed themes scanned from `/usr/share/sounds/*/index.theme` (name + directory).
|
||||
- `previewAlert()` — plays the current theme's bell (`bell.oga`, fallback to freedesktop's) via
|
||||
the SoundTest pw-play pattern, through the default sink.
|
||||
|
||||
**`Notifs.qml`**: when a notification popup is shown and `SoundFeedback.eventSounds` is true,
|
||||
play the current theme's bell via a dedicated Process (`pw-play`), throttled so a burst of
|
||||
notifications plays at most one sound per second. Low-urgency notifications stay silent.
|
||||
|
||||
**Schema** — new group `sound` (first audio keys in the schema; comments above entry braces):
|
||||
- `overAmplification` bool, def false, label "Over-amplification", detail per mock ("Lets the
|
||||
volume slider go to 150% — louder, at the cost of distortion on some hardware").
|
||||
- `volumeChangeBlip` bool, def true, label "Volume-change blip", detail per mock.
|
||||
`SettingsSearch.groupPages` gains `"sound": "sound"`.
|
||||
|
||||
**Over-amplification plumbing** (max is **1.5**, everywhere gated on the setting):
|
||||
- `AudioStreams.setVolume(nodes, volume, max)` — clamp bound becomes a parameter (pure JS stays
|
||||
Settings-free); callers pass `Settings.overAmplification ? 1.5 : 1`.
|
||||
- Output volume sliders (SoundPage, quicksettings `AudioSlider` for the sink only — the mic
|
||||
slider stays 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.
|
||||
@@ -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"
|
||||
|
||||
@@ -94,6 +94,13 @@ elif [[ $1 == "status" ]]; then
|
||||
fi
|
||||
SH
|
||||
|
||||
cat >"$scratch/bin/pw-play" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'pw-play' >>"$OSD_TEST_LOG"
|
||||
printf ' <%s>' "$@" >>"$OSD_TEST_LOG"
|
||||
printf '\n' >>"$OSD_TEST_LOG"
|
||||
SH
|
||||
|
||||
cat >"$scratch/bin/qs" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'qs' >>"$OSD_TEST_LOG"
|
||||
@@ -104,10 +111,31 @@ SH
|
||||
|
||||
chmod +x "$scratch/bin/"*
|
||||
|
||||
# The blip and the over-amplification limit are both read out of Panama's own
|
||||
# settings.json, so every run below gets its own config root. Without this the
|
||||
# helper would read Gabriel's real preferences and the assertions would pass or
|
||||
# fail depending on which switches he happens to have on.
|
||||
blip_sound="$scratch/blip.oga"
|
||||
: >"$blip_sound"
|
||||
mkdir -p "$scratch/config-default"
|
||||
|
||||
# Write a settings.json for one run. No arguments means no file at all, which
|
||||
# is what a fresh install looks like: the helper must fall back to the schema
|
||||
# defaults rather than treating an absent file as an error.
|
||||
settings_root() {
|
||||
local name="$1" body="${2:-}" root="$scratch/config-$name"
|
||||
rm -rf "$root"
|
||||
mkdir -p "$root/panama"
|
||||
[[ -n "$body" ]] && printf '%s\n' "$body" >"$root/panama/settings.json"
|
||||
printf '%s' "$root"
|
||||
}
|
||||
|
||||
run_helper() {
|
||||
local runtime="${OSD_RUNTIME_DIR:-$scratch/runtime-default}"
|
||||
mkdir -p "$runtime"
|
||||
PATH="$scratch/bin:$PATH" OSD_TEST_LOG="$log" \
|
||||
XDG_CONFIG_HOME="${OSD_CONFIG_HOME:-$scratch/config-default}" \
|
||||
PANAMA_OSD_BLIP_SOUND="${OSD_BLIP_SOUND:-$blip_sound}" \
|
||||
OSD_TEST_FAIL_QS="${OSD_TEST_FAIL_QS:-false}" \
|
||||
PANAMA_OSD_STRICT="${PANAMA_OSD_STRICT:-false}" \
|
||||
PANAMA_OSD_BRIGHTNESS_HELPER="$scratch/bin/panama-brightness" \
|
||||
@@ -132,6 +160,31 @@ assert_line() {
|
||||
}
|
||||
}
|
||||
|
||||
# The blip is backgrounded and never waited on -- deliberately, so it cannot sit
|
||||
# between the key press and the OSD. Which means the line may land after the
|
||||
# helper has already exited, and asserting it needs a moment rather than an
|
||||
# instant. Waiting here also keeps a slow blip from bleeding into the next
|
||||
# case's freshly truncated log.
|
||||
await_line() {
|
||||
local expected="$1"
|
||||
for _ in $(seq 1 60); do
|
||||
grep -Fqx -- "$expected" "$log" && return 0
|
||||
sleep 0.05
|
||||
done
|
||||
printf 'osd helper contract: missing call\n%s\nactual:\n' "$expected" >&2
|
||||
cat "$log" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
refute_line() {
|
||||
local unexpected="$1" why="$2"
|
||||
if grep -Fq -- "$unexpected" "$log"; then
|
||||
printf 'osd helper contract: %s\nunexpected: %s\nactual:\n' "$why" "$unexpected" >&2
|
||||
cat "$log" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
: >"$log"
|
||||
run_helper volume up 6
|
||||
assert_line 'wpctl <set-volume> <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>'
|
||||
@@ -148,6 +201,86 @@ WPCTL_OUTPUT='Volume: 0.72 [MUTED]' run_helper microphone toggle
|
||||
assert_line 'wpctl <set-mute> <@DEFAULT_AUDIO_SOURCE@> <toggle>'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <microphone-muted> <72> <100> <Muted>'
|
||||
|
||||
# ── Over-amplification: the ceiling is a setting, not a constant ─────────────
|
||||
# `wpctl set-volume` clamps to 1.0 unless told otherwise, and it clamps the
|
||||
# *result* -- so the limit has to be on the down step too. Without it, coming
|
||||
# down from 130% would snap to 100% instead of stepping to 124%, which reads as
|
||||
# the slider jumping on its own.
|
||||
: >"$log"
|
||||
OSD_CONFIG_HOME="$(settings_root overamp '{"overAmplification": true}')" \
|
||||
run_helper volume up 6
|
||||
assert_line 'wpctl <set-volume> <-l> <1.5> <@DEFAULT_AUDIO_SINK@> <6%+>'
|
||||
|
||||
: >"$log"
|
||||
OSD_CONFIG_HOME="$(settings_root overamp '{"overAmplification": true}')" \
|
||||
run_helper volume down 6
|
||||
assert_line 'wpctl <set-volume> <-l> <1.5> <@DEFAULT_AUDIO_SINK@> <6%->'
|
||||
|
||||
# The microphone is not part of the bargain: no amount of gain past 100% makes
|
||||
# a capture device better, and the Sound page's input slider stays 0-100 to
|
||||
# match.
|
||||
: >"$log"
|
||||
OSD_CONFIG_HOME="$(settings_root overamp '{"overAmplification": true}')" \
|
||||
run_helper microphone up 6
|
||||
assert_line 'wpctl <set-volume> <-l> <1> <@DEFAULT_AUDIO_SOURCE@> <6%+>'
|
||||
refute_line '<-l> <1.5> <@DEFAULT_AUDIO_SOURCE@>' \
|
||||
'over-amplification leaked onto the microphone'
|
||||
|
||||
: >"$log"
|
||||
OSD_CONFIG_HOME="$(settings_root plain '{"overAmplification": false}')" \
|
||||
run_helper volume up 6
|
||||
assert_line 'wpctl <set-volume> <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>'
|
||||
|
||||
# A settings file that predates the key, and a settings file that is not JSON
|
||||
# at all, both mean "off" rather than "no volume keys work today".
|
||||
: >"$log"
|
||||
OSD_CONFIG_HOME="$(settings_root nokey '{"barBackdrop": true}')" run_helper volume up 6
|
||||
assert_line 'wpctl <set-volume> <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>'
|
||||
|
||||
: >"$log"
|
||||
OSD_CONFIG_HOME="$(settings_root broken '{not json')" run_helper volume up 6
|
||||
assert_line 'wpctl <set-volume> <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <volume> <58> <100> <58%>'
|
||||
|
||||
# ── The volume blip ─────────────────────────────────────────────────────────
|
||||
# Default on, per the schema, so a settings.json that has never been written
|
||||
# still clicks. Fire-and-forget: the blip must never gate the OSD.
|
||||
: >"$log"
|
||||
run_helper volume up 6
|
||||
await_line "pw-play <$blip_sound>"
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <volume> <58> <100> <58%>'
|
||||
|
||||
: >"$log"
|
||||
run_helper volume down 6
|
||||
await_line "pw-play <$blip_sound>"
|
||||
|
||||
: >"$log"
|
||||
WPCTL_OUTPUT='Volume: 0.58' run_helper volume toggle
|
||||
await_line "pw-play <$blip_sound>"
|
||||
|
||||
: >"$log"
|
||||
OSD_CONFIG_HOME="$(settings_root silent '{"volumeChangeBlip": false}')" \
|
||||
run_helper volume up 6
|
||||
refute_line 'pw-play' 'the blip played with volumeChangeBlip off'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <volume> <58> <100> <58%>'
|
||||
|
||||
# Brightness and the microphone are not volume changes.
|
||||
: >"$log"
|
||||
run_helper brightness up 5
|
||||
refute_line 'pw-play' 'a brightness step played the volume blip'
|
||||
|
||||
: >"$log"
|
||||
WPCTL_OUTPUT='Volume: 0.72' run_helper microphone up 6
|
||||
refute_line 'pw-play' 'a microphone step played the volume blip'
|
||||
|
||||
# A distribution without the freedesktop sound theme has no file to play. That
|
||||
# is a silent desktop, not a broken volume key.
|
||||
: >"$log"
|
||||
OSD_BLIP_SOUND="$scratch/nothing-here.oga" run_helper volume up 6
|
||||
refute_line 'pw-play' 'the blip ran against a file that does not exist'
|
||||
assert_line 'wpctl <set-volume> <-l> <1> <@DEFAULT_AUDIO_SINK@> <6%+>'
|
||||
assert_line 'qs <ipc> <call> <osd> <progress> <volume> <58> <100> <58%>'
|
||||
|
||||
: >"$log"
|
||||
run_helper brightness up 5
|
||||
assert_line 'brightnessctl <-m> <-c> <backlight>'
|
||||
|
||||
Executable
+373
@@ -0,0 +1,373 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Device profiles are the last thing the Sound page handed to GNOME Settings,
|
||||
# and the reason it had to was that reading them means shelling out to pactl.
|
||||
# SoundCards does that, so the page can show the profile dropdown itself.
|
||||
#
|
||||
# What is worth pinning is the parsing, because pactl's JSON is not the shape
|
||||
# the UI wants and every mistake in the conversion is invisible until someone
|
||||
# with a headset and a surround card opens the page:
|
||||
#
|
||||
# * profiles arrive keyed by name in an object, and come out as an ordered
|
||||
# list, because a dropdown has an order and an object does not;
|
||||
# * a profile pactl marked unavailable is kept and flagged, not dropped --
|
||||
# "Headset" missing entirely is a bug report, "Headset (unavailable)" is an
|
||||
# explanation;
|
||||
# * the port hint says what is physically plugged in, which is the one thing
|
||||
# the profile name never tells you;
|
||||
# * pactl failing produces an error string, not an empty list that reads as
|
||||
# "this machine has no sound card".
|
||||
#
|
||||
# Runs against canned pactl output. The real audio graph is never touched:
|
||||
# pactl is replaced on PATH and through the service's own helper seam.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
service="$repo_dir/config/dot/quickshell/services/SoundCards.qml"
|
||||
harness="$repo_dir/config/dot/quickshell/sound-services-harness.qml"
|
||||
fixture="$(mktemp -d /tmp/panama-sound-cards.XXXXXX)"
|
||||
state_home="$fixture/state"
|
||||
shell_log="$fixture/quickshell.log"
|
||||
harness_pid=""
|
||||
|
||||
fail() {
|
||||
printf 'sound cards contract: %s\n' "$1" >&2
|
||||
[[ -s "$shell_log" ]] && sed -n '1,80p' "$shell_log" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -f "$service" ]] || fail 'SoundCards.qml is missing'
|
||||
[[ -f "$harness" ]] || fail 'sound-services-harness.qml is missing'
|
||||
|
||||
mkdir -p "$fixture/bin" "$state_home"
|
||||
|
||||
# Two cards, chosen for the two things that go wrong. The built-in card has a
|
||||
# connected line out and an available analog profile. The headset has no port
|
||||
# connected at all and a profile pactl reports as unavailable, which is what a
|
||||
# Bluetooth device looks like between "paired" and "actually here".
|
||||
cat >"$fixture/cards.good.json" <<'JSON'
|
||||
[
|
||||
{
|
||||
"index": 47,
|
||||
"name": "alsa_card.pci-0000_00_1f.3",
|
||||
"driver": "module-alsa-card.c",
|
||||
"properties": {
|
||||
"device.description": "Built-in Audio",
|
||||
"device.api": "alsa"
|
||||
},
|
||||
"profiles": {
|
||||
"off": {
|
||||
"description": "Off",
|
||||
"sinks": 0,
|
||||
"sources": 0,
|
||||
"priority": 0,
|
||||
"available": true
|
||||
},
|
||||
"output:hdmi-stereo": {
|
||||
"description": "Digital Stereo (HDMI) Output",
|
||||
"sinks": 1,
|
||||
"sources": 0,
|
||||
"priority": 5900,
|
||||
"available": false
|
||||
},
|
||||
"output:analog-stereo+input:analog-stereo": {
|
||||
"description": "Analog Stereo Duplex",
|
||||
"sinks": 1,
|
||||
"sources": 1,
|
||||
"priority": 6565,
|
||||
"available": true
|
||||
}
|
||||
},
|
||||
"active_profile": "output:analog-stereo+input:analog-stereo",
|
||||
"ports": {
|
||||
"analog-output-lineout": {
|
||||
"description": "Line Out",
|
||||
"type": "Line",
|
||||
"priority": 9900,
|
||||
"availability_group": "Legacy 1",
|
||||
"availability": "available"
|
||||
},
|
||||
"analog-output-headphones": {
|
||||
"description": "Headphones",
|
||||
"type": "Headphones",
|
||||
"priority": 9000,
|
||||
"availability_group": "Legacy 2",
|
||||
"availability": "not available"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 51,
|
||||
"name": "bluez_card.74_15_F5_13_A4_28",
|
||||
"driver": "module-bluez5-device.c",
|
||||
"properties": {
|
||||
"device.description": "WH-1000XM4",
|
||||
"device.api": "bluez5"
|
||||
},
|
||||
"profiles": {
|
||||
"a2dp-sink": {
|
||||
"description": "High Fidelity Playback (A2DP Sink)",
|
||||
"sinks": 1,
|
||||
"sources": 0,
|
||||
"priority": 40,
|
||||
"available": true
|
||||
},
|
||||
"headset-head-unit": {
|
||||
"description": "Headset Head Unit (HSP/HFP)",
|
||||
"sinks": 1,
|
||||
"sources": 1,
|
||||
"priority": 30,
|
||||
"available": false
|
||||
}
|
||||
},
|
||||
"active_profile": "a2dp-sink",
|
||||
"ports": {
|
||||
"bluez-output": {
|
||||
"description": "Headphone",
|
||||
"type": "Headphones",
|
||||
"priority": 0,
|
||||
"availability_group": "",
|
||||
"availability": "not available"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
JSON
|
||||
|
||||
# The read side never runs pactl: SoundCards' own seam,
|
||||
# PANAMA_SOUND_CARDS_FIXTURE, points it at a file it `cat`s instead. So this
|
||||
# stub exists for the *write* side -- `pactl set-card-profile` -- and to prove
|
||||
# the read side did not quietly fall back to the live daemon.
|
||||
cat >"$fixture/bin/pactl" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
printf 'pactl' >>"$PANAMA_SOUND_PACTL_LOG"
|
||||
printf ' <%s>' "$@" >>"$PANAMA_SOUND_PACTL_LOG"
|
||||
printf '\n' >>"$PANAMA_SOUND_PACTL_LOG"
|
||||
for arg in "$@"; do
|
||||
[[ "$arg" == "cards" ]] && { printf '[]\n'; exit 0; }
|
||||
done
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$fixture/bin/pactl"
|
||||
|
||||
# SoundDefaults shares this harness and reads on construction. Give it a
|
||||
# fixture of its own so it cannot reach the session's real metadata.
|
||||
: >"$fixture/defaults"
|
||||
|
||||
export PANAMA_SOUND_PACTL_LOG="$fixture/pactl.log"
|
||||
export PANAMA_SOUND_CARDS_FIXTURE="$fixture/cards.json"
|
||||
export PANAMA_SOUND_DEFAULTS_FIXTURE="$fixture/defaults"
|
||||
: >"$PANAMA_SOUND_PACTL_LOG"
|
||||
|
||||
# The seam is a path read once at construction, so a case changes what the
|
||||
# file says rather than where it points. `absent` deletes it, which is how a
|
||||
# read that fails looks from the service's side.
|
||||
cards_fixture() {
|
||||
case "$1" in
|
||||
good) cp "$fixture/cards.good.json" "$PANAMA_SOUND_CARDS_FIXTURE" ;;
|
||||
malformed) printf 'Failure: Module initialization failed\n' >"$PANAMA_SOUND_CARDS_FIXTURE" ;;
|
||||
absent) rm -f "$PANAMA_SOUND_CARDS_FIXTURE" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
instances_for_harness() {
|
||||
qs list --all 2>/dev/null | awk -v expected="$harness" '
|
||||
/^Instance / { pid = "" }
|
||||
/^[[:space:]]*Process ID:/ { pid = $3 }
|
||||
/^[[:space:]]*Config path:/ {
|
||||
path = $0
|
||||
sub(/^[[:space:]]*Config path: /, "", path)
|
||||
if (path == expected && pid ~ /^[0-9]+$/) print pid
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [[ "$harness_pid" =~ ^[0-9]+$ ]] && kill -0 "$harness_pid" 2>/dev/null; then
|
||||
kill "$harness_pid" 2>/dev/null || true
|
||||
for _ in $(seq 1 40); do
|
||||
kill -0 "$harness_pid" 2>/dev/null || break
|
||||
sleep 0.05
|
||||
done
|
||||
fi
|
||||
rm -rf "$fixture"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
run() {
|
||||
PATH="$fixture/bin:$PATH" \
|
||||
XDG_STATE_HOME="$state_home" \
|
||||
PANAMA_SOUND_PACTL_LOG="$PANAMA_SOUND_PACTL_LOG" \
|
||||
PANAMA_SOUND_CARDS_FIXTURE="$PANAMA_SOUND_CARDS_FIXTURE" \
|
||||
PANAMA_SOUND_DEFAULTS_FIXTURE="$PANAMA_SOUND_DEFAULTS_FIXTURE" \
|
||||
qs -p "$harness" "$@"
|
||||
}
|
||||
|
||||
ipc() {
|
||||
if [[ "$harness_pid" =~ ^[0-9]+$ ]]; then
|
||||
run ipc --pid "$harness_pid" "$@"
|
||||
else
|
||||
run ipc "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
run --daemonize >"$shell_log" 2>&1 || fail 'sound services harness did not launch'
|
||||
for _ in $(seq 1 60); do
|
||||
harness_pid="$(instances_for_harness | head -1)"
|
||||
if [[ "$harness_pid" =~ ^[0-9]+$ ]] \
|
||||
&& ipc show 2>/dev/null | rg -q '^target sound-services-test$'; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'sound services harness process did not start'
|
||||
ipc show 2>/dev/null | rg -q '^target sound-services-test$' \
|
||||
|| fail 'sound-services-test IPC target did not register'
|
||||
|
||||
# ── The good parse ───────────────────────────────────────────────────────────
|
||||
cards_fixture good
|
||||
ipc call sound-services-test refreshCards >/dev/null
|
||||
state=""
|
||||
for _ in $(seq 1 60); do
|
||||
state="$(ipc call sound-services-test cards)"
|
||||
[[ "$(jq -r '.cards | length' <<<"$state")" == "2" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.cards | length == 2' >/dev/null <<<"$state" \
|
||||
|| fail "canned pactl output did not become two cards: $state"
|
||||
jq -e '.lastError == ""' >/dev/null <<<"$state" \
|
||||
|| fail "a successful listing carried an error: $state"
|
||||
|
||||
# The fixture is the whole read. A service that fell back to the live daemon
|
||||
# when handed one would pass every assertion below on the developer's machine
|
||||
# and none of them on anyone else's.
|
||||
if grep -Fq 'list' "$PANAMA_SOUND_PACTL_LOG"; then
|
||||
fail "the fixture was ignored and the live daemon was read: $(cat "$PANAMA_SOUND_PACTL_LOG")"
|
||||
fi
|
||||
|
||||
# Which leaves the live command itself unexercised, so pin it where it is
|
||||
# written. `-f json` is the load-bearing half: without it pactl prints a
|
||||
# human-readable block that JSON.parse rejects, and every card disappears.
|
||||
rg -Fq '"pactl", "-f", "json", "list", "cards"' "$service" \
|
||||
|| fail 'the live card listing is not the JSON one'
|
||||
|
||||
jq -e '.cards[0].name == "alsa_card.pci-0000_00_1f.3"
|
||||
and .cards[0].description == "Built-in Audio"
|
||||
and .cards[0].activeProfile == "output:analog-stereo+input:analog-stereo"' \
|
||||
>/dev/null <<<"$state" || fail "the built-in card lost its identity: $state"
|
||||
|
||||
# Profiles become an ordered list. An object has no order, and a dropdown does.
|
||||
jq -e '.cards[0].profiles | type == "array"' >/dev/null <<<"$state" \
|
||||
|| fail "profiles are still keyed by name, so the dropdown has no order: $state"
|
||||
jq -e '(.cards[0].profiles | map(.name)) | index("output:analog-stereo+input:analog-stereo") != null
|
||||
and (.cards[0].profiles | map(.name) | index("output:hdmi-stereo")) != null' \
|
||||
>/dev/null <<<"$state" || fail "a profile pactl reported went missing: $state"
|
||||
jq -e '(.cards[0].profiles[] | select(.name == "output:analog-stereo+input:analog-stereo") | .description)
|
||||
== "Analog Stereo Duplex"' >/dev/null <<<"$state" \
|
||||
|| fail "profiles are labelled by their internal name rather than their description: $state"
|
||||
|
||||
# The order is pactl's priority, descending -- the order PulseAudio and GNOME
|
||||
# both present, and the one that puts "Off" at the bottom where nobody clicks
|
||||
# it by accident. The fixture's priorities (6565, 5900, 0) are deliberately not
|
||||
# the order the JSON lists them in, so a service that kept insertion order
|
||||
# fails here.
|
||||
jq -e '(.cards[0].profiles | map(.name))
|
||||
== ["output:analog-stereo+input:analog-stereo", "output:hdmi-stereo", "off"]' \
|
||||
>/dev/null <<<"$state" || fail "profiles are not ordered by priority: $state"
|
||||
|
||||
# Unavailable profiles are kept and flagged. Dropping them is how a card ends
|
||||
# up silently missing the mode someone is looking for.
|
||||
jq -e '(.cards[0].profiles[] | select(.name == "output:hdmi-stereo") | .available) == false
|
||||
and (.cards[0].profiles[] | select(.name == "output:analog-stereo+input:analog-stereo") | .available) == true' \
|
||||
>/dev/null <<<"$state" || fail "profile availability was not carried through: $state"
|
||||
|
||||
# The port hint says what is plugged in. The spec's own example is "Line out
|
||||
# connected"; this pins the two halves rather than the exact casing, so a
|
||||
# sentence tweak does not read as a regression -- but a hint that names the
|
||||
# wrong port, or none, does.
|
||||
hint="$(jq -r '.cards[0].portHint' <<<"$state")"
|
||||
[[ "$(tr '[:upper:]' '[:lower:]' <<<"$hint")" == *"line out"* ]] \
|
||||
|| fail "the built-in card's port hint does not name its connected port: $hint"
|
||||
[[ "$(tr '[:upper:]' '[:lower:]' <<<"$hint")" == *"connected"* ]] \
|
||||
|| fail "the port hint does not say the port is connected: $hint"
|
||||
|
||||
# Nothing plugged in says so, in the spec's words.
|
||||
[[ "$(jq -r '.cards[1].portHint' <<<"$state")" == "No port connected" ]] \
|
||||
|| fail "a card with no available port did not say so: $(jq -r '.cards[1].portHint' <<<"$state")"
|
||||
|
||||
jq -e '.cards[1].name == "bluez_card.74_15_F5_13_A4_28"
|
||||
and .cards[1].description == "WH-1000XM4"
|
||||
and .cards[1].activeProfile == "a2dp-sink"
|
||||
and ((.cards[1].profiles[] | select(.name == "headset-head-unit") | .available) == false)' \
|
||||
>/dev/null <<<"$state" || fail "the Bluetooth card did not survive the parse: $state"
|
||||
|
||||
# ── Switching a profile ──────────────────────────────────────────────────────
|
||||
: >"$PANAMA_SOUND_PACTL_LOG"
|
||||
ipc call sound-services-test setProfile \
|
||||
alsa_card.pci-0000_00_1f.3 output:hdmi-stereo >/dev/null
|
||||
for _ in $(seq 1 60); do
|
||||
grep -Fq 'set-card-profile' "$PANAMA_SOUND_PACTL_LOG" && break
|
||||
sleep 0.1
|
||||
done
|
||||
grep -Fq 'pactl <set-card-profile> <alsa_card.pci-0000_00_1f.3> <output:hdmi-stereo>' \
|
||||
"$PANAMA_SOUND_PACTL_LOG" \
|
||||
|| fail "profile switch did not reach pactl: $(cat "$PANAMA_SOUND_PACTL_LOG")"
|
||||
|
||||
# A card or profile nobody named is not a reason to run pactl with an empty
|
||||
# argument and let it decide.
|
||||
: >"$PANAMA_SOUND_PACTL_LOG"
|
||||
ipc call sound-services-test setProfile "" output:hdmi-stereo >/dev/null || true
|
||||
ipc call sound-services-test setProfile alsa_card.pci-0000_00_1f.3 "" >/dev/null || true
|
||||
sleep 0.3
|
||||
if grep -Fq 'set-card-profile' "$PANAMA_SOUND_PACTL_LOG"; then
|
||||
fail "an empty card or profile name still started a pactl write: $(cat "$PANAMA_SOUND_PACTL_LOG")"
|
||||
fi
|
||||
|
||||
# ── pactl answering with something that is not JSON ──────────────────────────
|
||||
cards_fixture malformed
|
||||
ipc call sound-services-test refreshCards >/dev/null
|
||||
for _ in $(seq 1 60); do
|
||||
state="$(ipc call sound-services-test cards)"
|
||||
[[ -n "$(jq -r '.lastError' <<<"$state")" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.lastError != "" and (.cards | type == "array")' >/dev/null <<<"$state" \
|
||||
|| fail "unparseable pactl output did not degrade into an error: $state"
|
||||
|
||||
# ── pactl not answering at all ───────────────────────────────────────────────
|
||||
cards_fixture absent
|
||||
ipc call sound-services-test refreshCards >/dev/null
|
||||
for _ in $(seq 1 60); do
|
||||
state="$(ipc call sound-services-test cards)"
|
||||
[[ -n "$(jq -r '.lastError' <<<"$state")" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.lastError != "" and .busy == false' >/dev/null <<<"$state" \
|
||||
|| fail "a failing pactl left the service busy or silent: $state"
|
||||
|
||||
# ── Recovery ─────────────────────────────────────────────────────────────────
|
||||
# An error is a state, not a terminal one. The card comes back on the next
|
||||
# refresh, and the error goes away with it.
|
||||
cards_fixture good
|
||||
ipc call sound-services-test refreshCards >/dev/null
|
||||
for _ in $(seq 1 60); do
|
||||
state="$(ipc call sound-services-test cards)"
|
||||
jq -e '.lastError == "" and (.cards | length) == 2' >/dev/null <<<"$state" && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.lastError == "" and (.cards | length) == 2' >/dev/null <<<"$state" \
|
||||
|| fail "the service never recovered from a failed listing: $state"
|
||||
|
||||
if rg -n 'ReferenceError|TypeError|Binding loop|Unable to assign|Cannot assign' "$shell_log"; then
|
||||
fail 'sound services harness emitted a QML runtime warning'
|
||||
fi
|
||||
|
||||
# The Sound page is a PipeWire surface. SoundCards is allowed to shell out --
|
||||
# it is the sibling singleton that exists so the page does not have to -- but
|
||||
# it must be the one place that speaks pactl about cards.
|
||||
rg -Fq 'pragma Singleton' "$service" || fail 'SoundCards is not a singleton'
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'sound cards contract: PASS\n'
|
||||
Executable
+279
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The device list used to show only what is present, which meant that unplugging
|
||||
# a headset made the row that owned the sound vanish and left no trace of why
|
||||
# the laptop speakers had taken over. PipeWire remembers the choice; the page
|
||||
# did not show it.
|
||||
#
|
||||
# SoundDefaults is what makes the ghost row possible. Its whole job is the
|
||||
# difference between two keys that look the same in a log:
|
||||
#
|
||||
# default.audio.sink what is playing right now
|
||||
# default.configured.audio.sink what was chosen, present or not
|
||||
#
|
||||
# Reading the first one is the bug, and it is a quiet one: it always names a
|
||||
# device that exists, so the ghost row never appears and the feature does
|
||||
# nothing on every machine where the configured device happens to be plugged
|
||||
# in -- which is most machines, most of the time. This pins the second.
|
||||
#
|
||||
# It also pins the label, because an absent device has no description to borrow
|
||||
# and the stored name is all there is to work with.
|
||||
#
|
||||
# Runs against canned metadata through the service's own fixture seam. The
|
||||
# session's real defaults are never read and never written.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
service="$repo_dir/config/dot/quickshell/services/SoundDefaults.qml"
|
||||
harness="$repo_dir/config/dot/quickshell/sound-services-harness.qml"
|
||||
fixture="$(mktemp -d /tmp/panama-sound-defaults.XXXXXX)"
|
||||
state_home="$fixture/state"
|
||||
shell_log="$fixture/quickshell.log"
|
||||
harness_pid=""
|
||||
|
||||
# A Bluetooth address no adapter in this house has ever seen, so the "not
|
||||
# present" assertions cannot be flipped by what happens to be paired during the
|
||||
# sweep. The effective sink is a plausible built-in card, and the point of the
|
||||
# fixture is that the two disagree.
|
||||
configured_sink="bluez_output.AA_BB_CC_11_22_33.1"
|
||||
effective_sink="alsa_output.pci-0000_00_1f.3.analog-stereo"
|
||||
configured_source="alsa_input.usb-Blue_Microphones_Yeti-00.analog-stereo"
|
||||
|
||||
fail() {
|
||||
printf 'sound defaults contract: %s\n' "$1" >&2
|
||||
[[ -s "$shell_log" ]] && sed -n '1,80p' "$shell_log" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -f "$service" ]] || fail 'SoundDefaults.qml is missing'
|
||||
[[ -f "$harness" ]] || fail 'sound-services-harness.qml is missing'
|
||||
|
||||
mkdir -p "$fixture/bin" "$state_home"
|
||||
|
||||
# The headset is the configured sink and is not here; the built-in card is what
|
||||
# is actually playing. A service that read the effective key would report the
|
||||
# built-in card and be wrong in the one case this exists for. The configured
|
||||
# line is written *first* so that "last key wins" is not what makes it pass.
|
||||
cat >"$fixture/metadata-absent" <<JSON
|
||||
Found "default" metadata 30
|
||||
update: id:0 key:'default.configured.audio.sink' value:'{"name":"$configured_sink"}' type:'Spa:String:JSON'
|
||||
update: id:0 key:'default.audio.sink' value:'{"name":"$effective_sink"}' type:'Spa:String:JSON'
|
||||
update: id:0 key:'default.configured.audio.source' value:'{"name":"$configured_source"}' type:'Spa:String:JSON'
|
||||
update: id:0 key:'default.audio.source' value:'{"name":"$configured_source"}' type:'Spa:String:JSON'
|
||||
JSON
|
||||
|
||||
# Nothing was ever configured: PipeWire is picking by priority. There is no
|
||||
# ghost to draw, and an empty string is how that has to read -- not the name of
|
||||
# whatever happens to be playing, which would render a ghost row for a device
|
||||
# that is present, sitting underneath its own real row.
|
||||
cat >"$fixture/metadata-unconfigured" <<JSON
|
||||
Found "default" metadata 30
|
||||
update: id:0 key:'default.audio.sink' value:'{"name":"$effective_sink"}' type:'Spa:String:JSON'
|
||||
update: id:0 key:'default.audio.source' value:'{"name":"$configured_source"}' type:'Spa:String:JSON'
|
||||
JSON
|
||||
|
||||
cat >"$fixture/metadata-empty" <<'JSON'
|
||||
Found "default" metadata 30
|
||||
JSON
|
||||
|
||||
# pw-metadata prints keys it does not own alongside the ones it does, and some
|
||||
# of them are bare strings rather than JSON. One of those must not take the
|
||||
# whole read down with it.
|
||||
cat >"$fixture/metadata-noise" <<JSON
|
||||
Found "default" metadata 30
|
||||
update: id:0 key:'log.level' value:'2' type:'Spa:String:JSON'
|
||||
update: id:0 key:'clock.force-quantum' value:'not json at all' type:'Spa:String:JSON'
|
||||
update: id:0 key:'default.configured.audio.sink' value:'{"name":"$configured_sink"}' type:'Spa:String:JSON'
|
||||
JSON
|
||||
|
||||
# SoundCards shares this harness. An inert fixture keeps it off the live daemon.
|
||||
printf '[]\n' >"$fixture/cards.json"
|
||||
|
||||
# Nothing here should reach pipewire, but if the fixture seam ever regressed,
|
||||
# these stubs are what stands between the contract and the session's real
|
||||
# defaults. They answer nothing, which fails the assertions loudly rather than
|
||||
# passing them against real hardware.
|
||||
for tool in pw-metadata pw-dump pactl wpctl; do
|
||||
printf '%s\n' '#!/usr/bin/env bash' 'exit 1' >"$fixture/bin/$tool"
|
||||
chmod +x "$fixture/bin/$tool"
|
||||
done
|
||||
|
||||
export PANAMA_SOUND_DEFAULTS_FIXTURE="$fixture/defaults"
|
||||
export PANAMA_SOUND_CARDS_FIXTURE="$fixture/cards.json"
|
||||
|
||||
# The seam is a path read once at construction, so a case changes what the file
|
||||
# says rather than where it points. `absent` deletes it, which is what a read
|
||||
# that fails looks like from the service's side.
|
||||
defaults_fixture() {
|
||||
if [[ "$1" == "failing" ]]; then
|
||||
rm -f "$PANAMA_SOUND_DEFAULTS_FIXTURE"
|
||||
else
|
||||
cp "$fixture/metadata-$1" "$PANAMA_SOUND_DEFAULTS_FIXTURE"
|
||||
fi
|
||||
}
|
||||
defaults_fixture absent
|
||||
|
||||
instances_for_harness() {
|
||||
qs list --all 2>/dev/null | awk -v expected="$harness" '
|
||||
/^Instance / { pid = "" }
|
||||
/^[[:space:]]*Process ID:/ { pid = $3 }
|
||||
/^[[:space:]]*Config path:/ {
|
||||
path = $0
|
||||
sub(/^[[:space:]]*Config path: /, "", path)
|
||||
if (path == expected && pid ~ /^[0-9]+$/) print pid
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [[ "$harness_pid" =~ ^[0-9]+$ ]] && kill -0 "$harness_pid" 2>/dev/null; then
|
||||
kill "$harness_pid" 2>/dev/null || true
|
||||
for _ in $(seq 1 40); do
|
||||
kill -0 "$harness_pid" 2>/dev/null || break
|
||||
sleep 0.05
|
||||
done
|
||||
fi
|
||||
rm -rf "$fixture"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
run() {
|
||||
PATH="$fixture/bin:$PATH" \
|
||||
XDG_STATE_HOME="$state_home" \
|
||||
PANAMA_SOUND_DEFAULTS_FIXTURE="$PANAMA_SOUND_DEFAULTS_FIXTURE" \
|
||||
PANAMA_SOUND_CARDS_FIXTURE="$PANAMA_SOUND_CARDS_FIXTURE" \
|
||||
qs -p "$harness" "$@"
|
||||
}
|
||||
|
||||
ipc() {
|
||||
if [[ "$harness_pid" =~ ^[0-9]+$ ]]; then
|
||||
run ipc --pid "$harness_pid" "$@"
|
||||
else
|
||||
run ipc "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Refresh, then wait for the parsed value to settle into the shape the filter
|
||||
# describes. Everything passed here goes to jq, so a case can bring its own
|
||||
# `--arg`.
|
||||
await_defaults() {
|
||||
local state=""
|
||||
ipc call sound-services-test refreshDefaults >/dev/null
|
||||
for _ in $(seq 1 60); do
|
||||
state="$(ipc call sound-services-test defaults)"
|
||||
jq -e "$@" >/dev/null <<<"$state" && { printf '%s' "$state"; return 0; }
|
||||
sleep 0.1
|
||||
done
|
||||
printf '%s' "$state"
|
||||
return 1
|
||||
}
|
||||
|
||||
run --daemonize >"$shell_log" 2>&1 || fail 'sound services harness did not launch'
|
||||
for _ in $(seq 1 60); do
|
||||
harness_pid="$(instances_for_harness | head -1)"
|
||||
if [[ "$harness_pid" =~ ^[0-9]+$ ]] \
|
||||
&& ipc show 2>/dev/null | rg -q '^target sound-services-test$'; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'sound services harness process did not start'
|
||||
|
||||
# ── Configured, and not here ─────────────────────────────────────────────────
|
||||
state="$(await_defaults --arg sink "$configured_sink" '.sink == $sink')" \
|
||||
|| fail "the configured sink was not read from the configured key: $state"
|
||||
[[ "$(jq -r .sink <<<"$state")" != "$effective_sink" ]] \
|
||||
|| fail 'the effective sink was reported as the configured one, so no ghost row can ever appear'
|
||||
[[ "$(jq -r .source <<<"$state")" == "$configured_source" ]] \
|
||||
|| fail "the configured source was not read: $state"
|
||||
|
||||
# ── The ghost row ────────────────────────────────────────────────────────────
|
||||
# Configured, absent from the graph, and labelled from the stored name -- which
|
||||
# is all there is, because the node that carried the description is gone.
|
||||
ghost="$(ipc call sound-services-test absent output)"
|
||||
jq -e --arg sink "$configured_sink" '.present == false and .name == $sink' \
|
||||
>/dev/null <<<"$ghost" || fail "a configured device that is not present did not become a ghost row: $ghost"
|
||||
[[ "$(jq -r .label <<<"$ghost")" == "Bluetooth device (AA:BB:CC:11:22:33)" ]] \
|
||||
|| fail "the ghost row's label is the raw node name: $(jq -r .label <<<"$ghost")"
|
||||
|
||||
# The label is the only part of a ghost row anyone reads, and the names it has
|
||||
# to work with are structured differently per transport.
|
||||
[[ "$(ipc call sound-services-test labelFor raop_sink.Living-Room.local.192.168.1.162.7000)" \
|
||||
== "Living Room" ]] \
|
||||
|| fail 'an AirPlay speaker is not named by its mDNS hostname'
|
||||
[[ "$(ipc call sound-services-test labelFor alsa_output.usb-Generic_USB_Audio-00.analog-stereo)" \
|
||||
== "Generic USB Audio" ]] \
|
||||
|| fail 'a USB device is not named by its product string'
|
||||
[[ "$(ipc call sound-services-test labelFor "")" == "" ]] \
|
||||
|| fail 'an empty name produced a label out of nothing'
|
||||
|
||||
# ── Keys that are not ours ───────────────────────────────────────────────────
|
||||
# pw-metadata prints the whole store, and some of it is not JSON. One bad line
|
||||
# must not take the read down with it.
|
||||
defaults_fixture noise
|
||||
state="$(await_defaults --arg sink "$configured_sink" '.sink == $sink')" \
|
||||
|| fail "a non-JSON value on an unrelated key discarded the whole read: $state"
|
||||
|
||||
# ── Nothing configured ───────────────────────────────────────────────────────
|
||||
defaults_fixture unconfigured
|
||||
state="$(await_defaults '.sink == ""')" \
|
||||
|| fail "an unconfigured default did not read as empty: $state"
|
||||
[[ "$(jq -r .source <<<"$state")" == "" ]] \
|
||||
|| fail "an unconfigured source did not read as empty: $state"
|
||||
jq -e '.present == true' >/dev/null <<<"$(ipc call sound-services-test absent output)" \
|
||||
|| fail 'a session with no configured default still drew a ghost row'
|
||||
|
||||
# ── An empty metadata store ──────────────────────────────────────────────────
|
||||
defaults_fixture empty
|
||||
state="$(await_defaults '.sink == "" and .source == ""')" \
|
||||
|| fail "an empty metadata store did not read as empty: $state"
|
||||
|
||||
# ── The read failing ─────────────────────────────────────────────────────────
|
||||
# A session where this cannot be read is a session with no ghost rows, which is
|
||||
# the pre-redesign behaviour and perfectly usable. It is not a session where the
|
||||
# Sound page reports a device nobody configured.
|
||||
defaults_fixture failing
|
||||
state="$(await_defaults '.sink == "" and .source == ""')" \
|
||||
|| fail "a failed read left a stale or invented configured device: $state"
|
||||
|
||||
# ── Recovery ─────────────────────────────────────────────────────────────────
|
||||
defaults_fixture absent
|
||||
state="$(await_defaults --arg sink "$configured_sink" '.sink == $sink')" \
|
||||
|| fail "the service never recovered after a failed read: $state"
|
||||
|
||||
if rg -n 'ReferenceError|TypeError|Binding loop|Unable to assign|Cannot assign' "$shell_log"; then
|
||||
fail 'sound services harness emitted a QML runtime warning'
|
||||
fi
|
||||
|
||||
# ── Static ───────────────────────────────────────────────────────────────────
|
||||
rg -Fq 'pragma Singleton' "$service" || fail 'SoundDefaults is not a singleton'
|
||||
|
||||
# The two greps that say which keys are being read. A regression here is
|
||||
# invisible at runtime on any machine whose configured device is plugged in.
|
||||
rg -Fq 'default.configured.audio.sink' "$service" \
|
||||
|| fail 'SoundDefaults does not name the configured sink key'
|
||||
rg -Fq 'default.configured.audio.source' "$service" \
|
||||
|| fail 'SoundDefaults does not name the configured source key'
|
||||
|
||||
# The live command, which the fixture seam means nothing above exercises.
|
||||
rg -Fq '"pw-metadata", "-n", "default", "0"' "$service" \
|
||||
|| fail 'the live read is not pw-metadata against the default metadata store'
|
||||
|
||||
# `Pipewire.preferredDefaultAudioSink` is the same configured value typed as a
|
||||
# node pointer, so it reads null in exactly the case this service exists for.
|
||||
# Binding the ghost row to it would make the ghost row impossible. The file's
|
||||
# own comment is allowed to say so; the code is not allowed to do it.
|
||||
python3 - "$service" <<'PY' || fail 'the configured device was read as a node pointer, which is null precisely when the device is absent'
|
||||
import sys
|
||||
|
||||
for line in open(sys.argv[1], encoding="utf-8"):
|
||||
if line.strip().startswith("//"):
|
||||
continue
|
||||
if "preferredDefaultAudio" in line:
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'sound defaults contract: PASS\n'
|
||||
@@ -4,26 +4,46 @@
|
||||
# another settings app. This contract keeps the real device plumbing shared
|
||||
# with Quick Settings and verifies the controls that must remain available.
|
||||
#
|
||||
# It also owns the line between Sound and Dictation. Dictation used to be a
|
||||
# It owns two lines that the page keeps wanting to cross.
|
||||
#
|
||||
# The first is the line between Sound and Dictation. Dictation used to be a
|
||||
# card on this page, because it listens through the input device chosen here.
|
||||
# It is an input method, so it now sits under Input with the keyboard -- and
|
||||
# the thing that made the old arrangement legible, that the microphone and the
|
||||
# dictation setup were visibly the same subject, has to survive the move as an
|
||||
# explicit handoff rather than as a second device picker.
|
||||
#
|
||||
# The second is the line between the page and the shell. Anything that speaks
|
||||
# pactl, wpctl or pw-metadata races the PipeWire objects AudioDevices already
|
||||
# holds, so it lives in a sibling singleton -- SoundTest, SoundFeedback, and
|
||||
# now SoundCards, SoundRouting and SoundDefaults. The device list, the rows,
|
||||
# the balance control and the service that owns discovery must stay native, and
|
||||
# no component on the page may grow a Process of its own.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
sound_page="$repo_dir/config/dot/quickshell/modules/settings/SoundPage.qml"
|
||||
dictation_page="$repo_dir/config/dot/quickshell/modules/settings/DictationPage.qml"
|
||||
device_list="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceList.qml"
|
||||
device_row="$repo_dir/config/dot/quickshell/modules/settings/SoundDeviceRow.qml"
|
||||
application_mixer="$repo_dir/config/dot/quickshell/modules/settings/ApplicationMixer.qml"
|
||||
application_row="$repo_dir/config/dot/quickshell/modules/settings/ApplicationVolumeRow.qml"
|
||||
balance="$repo_dir/config/dot/quickshell/modules/settings/AudioBalance.qml"
|
||||
audio_devices="$repo_dir/config/dot/quickshell/services/AudioDevices.qml"
|
||||
sound_feedback="$repo_dir/config/dot/quickshell/services/SoundFeedback.qml"
|
||||
settings="$repo_dir/config/dot/quickshell/modules/settings"
|
||||
services="$repo_dir/config/dot/quickshell/services"
|
||||
sound_page="$settings/SoundPage.qml"
|
||||
dictation_page="$settings/DictationPage.qml"
|
||||
device_list="$settings/SoundDeviceList.qml"
|
||||
device_row="$settings/SoundDeviceRow.qml"
|
||||
application_mixer="$settings/ApplicationMixer.qml"
|
||||
application_row="$settings/ApplicationVolumeRow.qml"
|
||||
balance="$settings/AudioBalance.qml"
|
||||
badge="$settings/SoundBadge.qml"
|
||||
capture_row="$settings/SoundCaptureRow.qml"
|
||||
channel_strip="$settings/SoundChannelStrip.qml"
|
||||
theme_row="$settings/SoundThemeRow.qml"
|
||||
volume_row="$settings/SoundVolumeRow.qml"
|
||||
audio_devices="$services/AudioDevices.qml"
|
||||
sound_feedback="$services/SoundFeedback.qml"
|
||||
sound_cards="$services/SoundCards.qml"
|
||||
sound_routing="$services/SoundRouting.qml"
|
||||
sound_defaults="$services/SoundDefaults.qml"
|
||||
quick_devices="$repo_dir/config/dot/quickshell/modules/quicksettings/AudioDeviceList.qml"
|
||||
quick_slider="$repo_dir/config/dot/quickshell/modules/quicksettings/AudioSlider.qml"
|
||||
harness="$repo_dir/config/dot/quickshell/sound-page-harness.qml"
|
||||
config_home="$(mktemp -d /tmp/panama-sound-config.XXXXXX)"
|
||||
state_home="$(mktemp -d /tmp/panama-sound-state.XXXXXX)"
|
||||
@@ -34,8 +54,25 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# The harness reads the two shelling services through their fixture seams, so
|
||||
# the page's structure is measured against canned state rather than against
|
||||
# whatever hardware happens to be plugged in. The configured sink below is a
|
||||
# Bluetooth address nothing in this session can be, which is what makes the
|
||||
# ghost-row assertions deterministic.
|
||||
ghost_sink="bluez_output.AA_BB_CC_11_22_33.1"
|
||||
cards_fixture="$state_home/cards.json"
|
||||
defaults_fixture="$state_home/defaults"
|
||||
printf '[]\n' >"$cards_fixture"
|
||||
cat >"$defaults_fixture" <<JSON
|
||||
Found "default" metadata 30
|
||||
update: id:0 key:'default.configured.audio.sink' value:'{"name":"$ghost_sink"}' type:'Spa:String:JSON'
|
||||
JSON
|
||||
|
||||
qs_for_harness() {
|
||||
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" qs -p "$harness" "$@"
|
||||
XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \
|
||||
PANAMA_SOUND_CARDS_FIXTURE="$cards_fixture" \
|
||||
PANAMA_SOUND_DEFAULTS_FIXTURE="$defaults_fixture" \
|
||||
qs -p "$harness" "$@"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
@@ -44,7 +81,14 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
for file in "$sound_page" "$dictation_page" "$device_list" "$device_row" "$application_mixer" "$application_row" "$balance" "$audio_devices" "$sound_feedback" "$quick_devices"; do
|
||||
core_files=("$audio_devices" "$device_list" "$device_row" "$balance")
|
||||
page_components=("$sound_page" "$device_list" "$device_row" "$application_mixer"
|
||||
"$application_row" "$balance" "$badge" "$capture_row" "$channel_strip"
|
||||
"$theme_row" "$volume_row")
|
||||
|
||||
for file in "${page_components[@]}" "$dictation_page" "$audio_devices" \
|
||||
"$sound_feedback" "$sound_cards" "$sound_routing" "$sound_defaults" \
|
||||
"$quick_devices" "$quick_slider"; do
|
||||
[[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}"
|
||||
done
|
||||
|
||||
@@ -73,12 +117,121 @@ rg -Fq 'root.node.audio.volumes = next;' "$balance" || fail 'balance does not wr
|
||||
[[ "$(rg -c 'SoundDeviceList \{' "$sound_page")" -eq 2 ]] || fail 'Sound page does not expose output and input device lists'
|
||||
rg -Fq 'AudioBalance {' "$sound_page" || fail 'Sound page has no output balance control'
|
||||
rg -Fq 'ApplicationMixer {' "$sound_page" || fail 'Sound page has no Applications mixer'
|
||||
rg -Fq 'label: "Device profiles"' "$sound_page" || fail 'advanced handoff is not limited to device profiles'
|
||||
rg -Fq 'SystemSettings.openGnomePanel("sound")' "$sound_page" || fail 'advanced GNOME Sound handoff was removed'
|
||||
rg -Fq 'SoundFeedback.setEventSounds(checked)' "$sound_page" || fail 'event sounds are not controllable'
|
||||
rg -Fq 'SoundFeedback.setInputFeedback(checked)' "$sound_page" || fail 'input feedback sounds are not controllable'
|
||||
rg -Fq 'org.gnome.desktop.sound' "$sound_feedback" || fail 'sound feedback does not use the desktop sound schema'
|
||||
|
||||
# ── Device profiles are native now ───────────────────────────────────────────
|
||||
# This page used to end in a button that opened GNOME's Sound panel, because
|
||||
# reading a card's profiles means shelling out and the page may not. SoundCards
|
||||
# does the shelling, so the handoff is gone -- and it has to stay gone, because
|
||||
# an "Open panel" button next to a working dropdown is a second answer to the
|
||||
# same question.
|
||||
! rg -Fq 'openGnomePanel("sound")' "$sound_page" \
|
||||
|| fail 'the GNOME Sound handoff came back, next to the profile dropdown that replaced it'
|
||||
! rg -Fq 'label: "Device profiles"' "$sound_page" \
|
||||
|| fail 'device profiles are an ActionRow again rather than the native card'
|
||||
rg -Fq 'title: "Device profiles"' "$sound_page" || fail 'the Device profiles card is gone'
|
||||
rg -Fq 'SoundCards.cards' "$sound_page" || fail 'the profile card reads no cards'
|
||||
rg -Fq 'SoundCards.setProfile(' "$sound_page" || fail 'the profile dropdown writes nothing'
|
||||
rg -Fq 'SoundCards.refresh();' "$sound_page" || fail 'device profiles are never re-read on page open'
|
||||
rg -Fq 'SoundDefaults.refresh();' "$sound_page" || fail 'the configured defaults are never re-read on page open'
|
||||
rg -Fq 'SoundCards.lastError' "$sound_page" \
|
||||
|| fail 'a failed profile read degrades nothing, so the card would show an empty list instead of a reason'
|
||||
|
||||
# ── The microphone, and who is holding it ────────────────────────────────────
|
||||
rg -Fq 'PwNodeType.AudioInStream' "$audio_devices" \
|
||||
|| fail 'capture streams are not filtered by PipeWire type'
|
||||
rg -Fq 'captureApplications' "$audio_devices" || fail 'AudioDevices exposes no capture applications'
|
||||
rg -Fq 'AudioDevices.captureApplications' "$capture_row" \
|
||||
|| fail 'the microphone row does not read the grouped capture applications'
|
||||
rg -Fq 'AudioDevices.setApplicationMuted(' "$capture_row" \
|
||||
|| fail 'an application holding the microphone cannot be muted from the row'
|
||||
rg -Fq 'PwObjectTracker {' "$capture_row" \
|
||||
|| fail 'capture nodes are untracked, so their mute state reads false and the write is swallowed'
|
||||
|
||||
# Nothing listening means no row, not an empty one. "No applications are using
|
||||
# the microphone" is a sentence nobody needs on a page they opened to change an
|
||||
# output device.
|
||||
rg -Fq 'visible: root.users.length > 0' "$capture_row" \
|
||||
|| fail 'the microphone row renders itself when nothing is listening'
|
||||
|
||||
rg -Fq 'SoundTest.startMicTest(' "$sound_page" || fail 'there is no microphone test'
|
||||
rg -Fq 'SoundTest.micTestState' "$sound_page" \
|
||||
|| fail 'the microphone test button never says what it is doing'
|
||||
|
||||
# ── The ghost row ────────────────────────────────────────────────────────────
|
||||
# A configured device that is not present renders last, dimmed, and inert. The
|
||||
# two halves that make it honest: it is keyed off the *configured* name, and it
|
||||
# only appears when no present node carries that name.
|
||||
rg -Fq 'SoundDefaults.absent(root.output)' "$device_list" \
|
||||
|| fail 'the device list does not ask which configured device is missing, so it can draw no ghost row'
|
||||
rg -Fq 'ghost: true' "$device_list" || fail 'the list has no ghost row at all'
|
||||
rg -Fq 'Returns when connected' "$device_row" || fail 'the ghost row does not say why it is there'
|
||||
|
||||
# Non-interactive: selecting a device that is not here would ask PipeWire to
|
||||
# make a node that does not exist the default.
|
||||
python3 - "$device_row" <<'PY' || fail 'the ghost row is selectable, so a device that is not here can be chosen'
|
||||
import re
|
||||
import sys
|
||||
|
||||
source = open(sys.argv[1], encoding="utf-8").read()
|
||||
for handler in ("TapHandler", "HoverHandler"):
|
||||
for match in re.finditer(handler + r" \{(?P<body>.*?)\n \}", source, re.S):
|
||||
if "!root.ghost" not in match.group("body"):
|
||||
raise SystemExit(1)
|
||||
if "AudioDevices.select(" not in source:
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
# The ghost row renders after the present devices, not among them.
|
||||
python3 - "$device_list" <<'PY' || fail 'the ghost row is not last in the list'
|
||||
import sys
|
||||
|
||||
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
|
||||
repeater = next(i for i, line in enumerate(lines) if line.strip().startswith("Repeater"))
|
||||
ghost = next(i for i, line in enumerate(lines) if "ghost: true" in line)
|
||||
if ghost < repeater:
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
# ── Badges ───────────────────────────────────────────────────────────────────
|
||||
# Native, off the node's own properties. A transport badge derived from the
|
||||
# device name would be a guess.
|
||||
rg -Fq '"device.api"' "$device_row" || fail 'device badges are not read from the node properties'
|
||||
rg -Fq '"raop"' "$device_row" || fail 'AirPlay devices carry no badge'
|
||||
rg -Fq '"bluez5"' "$device_row" || fail 'Bluetooth devices carry no badge'
|
||||
|
||||
# ── Over-amplification ───────────────────────────────────────────────────────
|
||||
# One ceiling, one preference, and the input is never part of it: a microphone
|
||||
# above 100% is gain on noise, not loudness.
|
||||
rg -Fq 'overAmplification' "$sound_page" || fail 'the Sound page has no over-amplification control'
|
||||
rg -Fq '1.5 : 1' "$sound_page" || fail 'the output slider maximum is not gated on the preference'
|
||||
rg -Fq 'root.output' "$quick_slider" \
|
||||
|| fail "Quick Settings' slider does not distinguish the sink from the microphone"
|
||||
rg -Fq '1.5 : 1' "$quick_slider" || fail "Quick Settings' output slider cannot over-amplify"
|
||||
python3 - "$quick_slider" <<'PY' || fail 'over-amplification is not restricted to the output in Quick Settings'
|
||||
import re
|
||||
import sys
|
||||
|
||||
source = open(sys.argv[1], encoding="utf-8").read()
|
||||
match = re.search(r"property real maximum:(?P<body>.*?)\n\n", source, re.S)
|
||||
if not match or "root.output" not in match.group("body"):
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
rg -Fq 'root.maximum > 1' "$volume_row" \
|
||||
|| fail 'the region past 100% is not marked, so 150% looks like a full slider'
|
||||
|
||||
# ── Per-application routing ──────────────────────────────────────────────────
|
||||
rg -Fq 'SoundRouting.moveApplication(' "$application_row" \
|
||||
|| fail 'an application cannot be sent to another output'
|
||||
rg -Fq 'SoundRouting.routeToDefault(' "$application_row" \
|
||||
|| fail 'an application cannot be handed back to the system default'
|
||||
rg -Fq 'SoundRouting.currentSinkFor(' "$application_row" \
|
||||
|| fail 'the output picker does not say where the application is playing'
|
||||
rg -Fq 'label: "System default"' "$application_row" \
|
||||
|| fail 'following the system default is not an option anyone can pick'
|
||||
|
||||
# ── Dictation lives on its own page under Input ──────────────────────────────
|
||||
# One page owns the dictation controls. Two would mean two setup buttons
|
||||
# driving the same one-time install, and whichever one someone found second
|
||||
@@ -108,12 +261,28 @@ rg -Fq 'ShellState.openSettings("sound")' "$dictation_page" \
|
||||
! rg -Fq 'SoundDeviceList {' "$dictation_page" \
|
||||
|| fail 'DictationPage grew its own device picker -- there is one input device, and two places to change it disagree'
|
||||
|
||||
# Native bindings are the supported path. Shelling out would race the service
|
||||
# that owns these same objects and regress Quick Settings coherence.
|
||||
if rg -q '\b(Process|pactl|wpctl)\b' "$audio_devices" "$device_list" "$device_row" "$balance"; then
|
||||
# ── Native bindings are the supported path ───────────────────────────────────
|
||||
# Shelling out here would race the service that owns these same objects and
|
||||
# regress Quick Settings coherence.
|
||||
if rg -q '\b(Process|pactl|wpctl|pw-metadata)\b' "${core_files[@]}"; then
|
||||
fail 'Sound controls bypass the Quickshell PipeWire service'
|
||||
fi
|
||||
|
||||
# The ban extends to every component the rebuilt page is made of. Five new ones
|
||||
# landed with it, and the cheapest way for any of them to get something the
|
||||
# PipeWire bindings do not expose is a Process nobody noticed.
|
||||
for component in "${page_components[@]}"; do
|
||||
if rg -q '\bProcess\b' "$component"; then
|
||||
fail "${component#"$settings/"} shells out -- anything that does belongs in a sibling singleton beside SoundTest"
|
||||
fi
|
||||
done
|
||||
|
||||
# And the singletons that are allowed to shell out are the ones the spec names.
|
||||
for service in "$sound_cards" "$sound_routing" "$sound_defaults"; do
|
||||
rg -Fq 'pragma Singleton' "$service" \
|
||||
|| fail "${service#"$services/"} is not a singleton, so the page would hold its own copy of it"
|
||||
done
|
||||
|
||||
printf 'sound page static contract: PASS\n'
|
||||
|
||||
# Instantiate the complete page against the real, read-only PipeWire graph.
|
||||
@@ -140,6 +309,26 @@ jq -e '.ready == true and .outputs > 0 and .inputs > 0
|
||||
<<<"$status" >/dev/null \
|
||||
|| fail "real PipeWire graph was not represented: $status"
|
||||
|
||||
# Capture grouping is the playback grouping applied to the other direction, so
|
||||
# the same invariant holds: every node in a group is an input stream, and an
|
||||
# application recording on three streams is one entry.
|
||||
jq -e '.captureApplications >= 0 and .captureTypesValid == true' <<<"$status" >/dev/null \
|
||||
|| fail "capture applications were grouped from the wrong stream type: $status"
|
||||
jq -e '.captureRowVisible == (.captureApplications > 0)' <<<"$status" >/dev/null \
|
||||
|| fail "the microphone row does not follow whether anything is listening: $status"
|
||||
|
||||
# The ghost row, against a configured sink this session cannot possibly have.
|
||||
# The read is a subprocess, so it lands after the first status call.
|
||||
for _ in $(seq 1 60); do
|
||||
status="$(qs_for_harness ipc call sound-page-test status)"
|
||||
[[ "$(jq -r .ghostVisible <<<"$status")" == "true" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.ghostVisible == true' <<<"$status" >/dev/null \
|
||||
|| fail "a configured output that is not present did not produce a ghost row: $status"
|
||||
jq -e '.ghostLabel == "Bluetooth device (AA:BB:CC:11:22:33)"' <<<"$status" >/dev/null \
|
||||
|| fail "the ghost row is labelled with the raw node name: $status"
|
||||
|
||||
if rg -n 'ReferenceError|TypeError|Binding loop|Unable to assign|Cannot assign|PwObjectTracker' "$shell_log"; then
|
||||
fail 'Sound page emitted a QML runtime warning'
|
||||
fi
|
||||
|
||||
Executable
+301
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Sending one application to a different output is the thing every mixer on
|
||||
# every other desktop has and Panama's did not. SoundRouting is the singleton
|
||||
# that does it, because moving a stream means `pactl move-sink-input` and the
|
||||
# Sound page is banned from shelling out.
|
||||
#
|
||||
# The parts that are easy to get wrong and impossible to notice:
|
||||
#
|
||||
# * an application is a *group* of streams -- a browser playing two tabs has
|
||||
# two sink inputs, and moving one of them is worse than moving none;
|
||||
# * pactl wants the stream's `object.serial`, not its node id, and the two
|
||||
# are different numbers that both look plausible in a log;
|
||||
# * a stream with no serial still has to move, on its node id;
|
||||
# * an application that stopped playing between the click and the call must
|
||||
# not turn into a pactl invocation with an empty argument;
|
||||
# * putting an application *back* on the system default is not the same call
|
||||
# in reverse -- moving it to the default sink pins it there, so it stops
|
||||
# following the moment the default moves again.
|
||||
#
|
||||
# Runs against recording pactl and pw-metadata stubs. No real stream is ever
|
||||
# moved and no real metadata key is ever deleted.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
service="$repo_dir/config/dot/quickshell/services/SoundRouting.qml"
|
||||
harness="$repo_dir/config/dot/quickshell/sound-services-harness.qml"
|
||||
fixture="$(mktemp -d /tmp/panama-sound-routing.XXXXXX)"
|
||||
state_home="$fixture/state"
|
||||
shell_log="$fixture/quickshell.log"
|
||||
harness_pid=""
|
||||
|
||||
fail() {
|
||||
printf 'sound routing contract: %s\n' "$1" >&2
|
||||
[[ -s "$shell_log" ]] && sed -n '1,80p' "$shell_log" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[[ -f "$service" ]] || fail 'SoundRouting.qml is missing'
|
||||
[[ -f "$harness" ]] || fail 'sound-services-harness.qml is missing'
|
||||
|
||||
mkdir -p "$fixture/bin" "$state_home"
|
||||
|
||||
cat >"$fixture/bin/pactl" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
printf 'pactl' >>"$PANAMA_SOUND_PACTL_LOG"
|
||||
printf ' <%s>' "$@" >>"$PANAMA_SOUND_PACTL_LOG"
|
||||
printf '\n' >>"$PANAMA_SOUND_PACTL_LOG"
|
||||
|
||||
for arg in "$@"; do
|
||||
[[ "$arg" == "cards" ]] && { printf '[]\n'; exit 0; }
|
||||
done
|
||||
|
||||
if [[ "$(cat "$PANAMA_SOUND_MOVE_MODE" 2>/dev/null || printf 'good')" == "failing" ]]; then
|
||||
printf 'Failure: No such entity\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$fixture/bin/pactl"
|
||||
|
||||
cat >"$fixture/bin/pw-metadata" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
printf 'pw-metadata' >>"$PANAMA_SOUND_PACTL_LOG"
|
||||
printf ' <%s>' "$@" >>"$PANAMA_SOUND_PACTL_LOG"
|
||||
printf '\n' >>"$PANAMA_SOUND_PACTL_LOG"
|
||||
exit 0
|
||||
STUB
|
||||
chmod +x "$fixture/bin/pw-metadata"
|
||||
|
||||
# SoundCards and SoundDefaults share this harness and would otherwise read the
|
||||
# live daemon. Both take a fixture path; give them inert ones so every line in
|
||||
# the log below came from SoundRouting.
|
||||
printf '[]\n' >"$fixture/cards.json"
|
||||
: >"$fixture/defaults"
|
||||
|
||||
export PANAMA_SOUND_PACTL_LOG="$fixture/pactl.log"
|
||||
export PANAMA_SOUND_MOVE_MODE="$fixture/mode"
|
||||
export PANAMA_SOUND_CARDS_FIXTURE="$fixture/cards.json"
|
||||
export PANAMA_SOUND_DEFAULTS_FIXTURE="$fixture/defaults"
|
||||
: >"$PANAMA_SOUND_PACTL_LOG"
|
||||
printf 'good\n' >"$PANAMA_SOUND_MOVE_MODE"
|
||||
|
||||
instances_for_harness() {
|
||||
qs list --all 2>/dev/null | awk -v expected="$harness" '
|
||||
/^Instance / { pid = "" }
|
||||
/^[[:space:]]*Process ID:/ { pid = $3 }
|
||||
/^[[:space:]]*Config path:/ {
|
||||
path = $0
|
||||
sub(/^[[:space:]]*Config path: /, "", path)
|
||||
if (path == expected && pid ~ /^[0-9]+$/) print pid
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [[ "$harness_pid" =~ ^[0-9]+$ ]] && kill -0 "$harness_pid" 2>/dev/null; then
|
||||
kill "$harness_pid" 2>/dev/null || true
|
||||
for _ in $(seq 1 40); do
|
||||
kill -0 "$harness_pid" 2>/dev/null || break
|
||||
sleep 0.05
|
||||
done
|
||||
fi
|
||||
rm -rf "$fixture"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
run() {
|
||||
PATH="$fixture/bin:$PATH" \
|
||||
XDG_STATE_HOME="$state_home" \
|
||||
PANAMA_SOUND_PACTL_LOG="$PANAMA_SOUND_PACTL_LOG" \
|
||||
PANAMA_SOUND_MOVE_MODE="$PANAMA_SOUND_MOVE_MODE" \
|
||||
PANAMA_SOUND_CARDS_FIXTURE="$PANAMA_SOUND_CARDS_FIXTURE" \
|
||||
PANAMA_SOUND_DEFAULTS_FIXTURE="$PANAMA_SOUND_DEFAULTS_FIXTURE" \
|
||||
qs -p "$harness" "$@"
|
||||
}
|
||||
|
||||
ipc() {
|
||||
if [[ "$harness_pid" =~ ^[0-9]+$ ]]; then
|
||||
run ipc --pid "$harness_pid" "$@"
|
||||
else
|
||||
run ipc "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
await_log() {
|
||||
local needle="$1"
|
||||
for _ in $(seq 1 60); do
|
||||
grep -Fq "$needle" "$PANAMA_SOUND_PACTL_LOG" && return 0
|
||||
sleep 0.1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
run --daemonize >"$shell_log" 2>&1 || fail 'sound services harness did not launch'
|
||||
for _ in $(seq 1 60); do
|
||||
harness_pid="$(instances_for_harness | head -1)"
|
||||
if [[ "$harness_pid" =~ ^[0-9]+$ ]] \
|
||||
&& ipc show 2>/dev/null | rg -q '^target sound-services-test$'; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
[[ "$harness_pid" =~ ^[0-9]+$ ]] || fail 'sound services harness process did not start'
|
||||
|
||||
# ── Every stream in the group moves ──────────────────────────────────────────
|
||||
: >"$PANAMA_SOUND_PACTL_LOG"
|
||||
ipc call sound-services-test moveApplication twoStream \
|
||||
alsa_output.pci-0000_00_1f.3.analog-stereo >/dev/null
|
||||
await_log 'move-sink-input' \
|
||||
|| fail 'moving an application never reached pactl'
|
||||
for _ in $(seq 1 40); do
|
||||
[[ "$(grep -Fc 'move-sink-input' "$PANAMA_SOUND_PACTL_LOG")" == "2" ]] && break
|
||||
sleep 0.05
|
||||
done
|
||||
grep -Fq 'pactl <move-sink-input> <412> <alsa_output.pci-0000_00_1f.3.analog-stereo>' \
|
||||
"$PANAMA_SOUND_PACTL_LOG" \
|
||||
|| fail "the first stream was not moved by its object.serial: $(cat "$PANAMA_SOUND_PACTL_LOG")"
|
||||
grep -Fq 'pactl <move-sink-input> <418> <alsa_output.pci-0000_00_1f.3.analog-stereo>' \
|
||||
"$PANAMA_SOUND_PACTL_LOG" \
|
||||
|| fail "the second stream of the same application stayed behind: $(cat "$PANAMA_SOUND_PACTL_LOG")"
|
||||
|
||||
# The node id is a different number that looks just as plausible in a log, and
|
||||
# pactl will happily move whatever stream happens to carry it.
|
||||
if grep -Eq 'move-sink-input> <(61|62)>' "$PANAMA_SOUND_PACTL_LOG"; then
|
||||
fail "streams were moved by node id instead of object.serial: $(cat "$PANAMA_SOUND_PACTL_LOG")"
|
||||
fi
|
||||
|
||||
state="$(ipc call sound-services-test routing)"
|
||||
for _ in $(seq 1 40); do
|
||||
state="$(ipc call sound-services-test routing)"
|
||||
jq -e '.busy == false' >/dev/null <<<"$state" && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.busy == false and .lastError == ""' >/dev/null <<<"$state" \
|
||||
|| fail "a successful move left the service busy or carrying an error: $state"
|
||||
|
||||
# ── A stream PipeWire never gave a serial still moves ────────────────────────
|
||||
: >"$PANAMA_SOUND_PACTL_LOG"
|
||||
ipc call sound-services-test moveApplication serialless \
|
||||
alsa_output.pci-0000_00_1f.3.analog-stereo >/dev/null
|
||||
await_log 'move-sink-input' || fail 'a serial-less stream was skipped entirely'
|
||||
grep -Fq 'pactl <move-sink-input> <77> <alsa_output.pci-0000_00_1f.3.analog-stereo>' \
|
||||
"$PANAMA_SOUND_PACTL_LOG" \
|
||||
|| fail "a stream with no object.serial did not fall back to its node id: $(cat "$PANAMA_SOUND_PACTL_LOG")"
|
||||
|
||||
# ── Nothing to move, nothing to run ──────────────────────────────────────────
|
||||
# An application that stopped playing between the click and the call is the
|
||||
# ordinary case, not an error, and it must not become `pactl move-sink-input
|
||||
# "" <sink>` -- which pactl answers by moving something else.
|
||||
: >"$PANAMA_SOUND_PACTL_LOG"
|
||||
ipc call sound-services-test moveApplication empty \
|
||||
alsa_output.pci-0000_00_1f.3.analog-stereo >/dev/null || true
|
||||
ipc call sound-services-test moveApplication twoStream "" >/dev/null || true
|
||||
sleep 0.3
|
||||
if grep -Fq 'move-sink-input' "$PANAMA_SOUND_PACTL_LOG"; then
|
||||
fail "an empty group or an unnamed sink still started a move: $(cat "$PANAMA_SOUND_PACTL_LOG")"
|
||||
fi
|
||||
|
||||
# ── Following the system default again ───────────────────────────────────────
|
||||
# Not the same call in reverse. `move-sink-input` to the current default *pins*
|
||||
# the stream there, so it would stop following and quietly stay behind the next
|
||||
# time the default moved -- the exact bug the button exists to undo. The pin
|
||||
# lives as a `target.object` key on the stream's node id in PipeWire's default
|
||||
# metadata, and releasing it means deleting that key.
|
||||
#
|
||||
# Note the id asymmetry, which is the other way to get this wrong: pactl speaks
|
||||
# object serials (412, 418) and pw-metadata speaks node ids (61, 62). They are
|
||||
# different numbers for the same stream and both look right in a log.
|
||||
: >"$PANAMA_SOUND_PACTL_LOG"
|
||||
ipc call sound-services-test routeToDefault twoStream >/dev/null
|
||||
await_log 'target.object' || fail 'returning an application to the default ran nothing'
|
||||
for _ in $(seq 1 60); do
|
||||
[[ "$(grep -Fc 'pw-metadata' "$PANAMA_SOUND_PACTL_LOG")" == "4" ]] && break
|
||||
sleep 0.05
|
||||
done
|
||||
grep -Fq 'pw-metadata <-n> <default> <-d> <61> <target.object>' "$PANAMA_SOUND_PACTL_LOG" \
|
||||
|| fail "the first stream's pin was not released: $(cat "$PANAMA_SOUND_PACTL_LOG")"
|
||||
grep -Fq 'pw-metadata <-n> <default> <-d> <62> <target.object>' "$PANAMA_SOUND_PACTL_LOG" \
|
||||
|| fail "the second stream of the same application stayed pinned: $(cat "$PANAMA_SOUND_PACTL_LOG")"
|
||||
|
||||
# Streams pinned by older tooling carry `target.node` instead, and a release
|
||||
# that only deletes one of the two keys leaves half the population stuck.
|
||||
grep -Fq 'pw-metadata <-n> <default> <-d> <61> <target.node>' "$PANAMA_SOUND_PACTL_LOG" \
|
||||
|| fail "the legacy target.node pin was left in place: $(cat "$PANAMA_SOUND_PACTL_LOG")"
|
||||
|
||||
if grep -Fq 'move-sink-input' "$PANAMA_SOUND_PACTL_LOG"; then
|
||||
fail "returning to the default moved the stream to a sink, which pins it there instead of releasing it: $(cat "$PANAMA_SOUND_PACTL_LOG")"
|
||||
fi
|
||||
if grep -Eq 'pw-metadata <.*> <(412|418)>' "$PANAMA_SOUND_PACTL_LOG"; then
|
||||
fail "pw-metadata was given an object serial where it wants a node id: $(cat "$PANAMA_SOUND_PACTL_LOG")"
|
||||
fi
|
||||
|
||||
: >"$PANAMA_SOUND_PACTL_LOG"
|
||||
ipc call sound-services-test routeToDefault empty >/dev/null || true
|
||||
sleep 0.3
|
||||
[[ ! -s "$PANAMA_SOUND_PACTL_LOG" ]] \
|
||||
|| fail "an empty group still ran a command: $(cat "$PANAMA_SOUND_PACTL_LOG")"
|
||||
|
||||
# ── Where an application is playing ──────────────────────────────────────────
|
||||
# Fixture streams are not in the real graph, so nothing links them to a sink.
|
||||
# That is exactly the "follows the system default" case, and it reads as an
|
||||
# empty string rather than "unknown", a null, or a thrown error.
|
||||
[[ "$(ipc call sound-services-test currentSinkFor twoStream)" == "" ]] \
|
||||
|| fail 'an unlinked stream did not read as following the system default'
|
||||
[[ "$(ipc call sound-services-test currentSinkFor empty)" == "" ]] \
|
||||
|| fail 'an application with no streams did not read as following the system default'
|
||||
|
||||
# ── pactl refusing ───────────────────────────────────────────────────────────
|
||||
printf 'failing\n' >"$PANAMA_SOUND_MOVE_MODE"
|
||||
: >"$PANAMA_SOUND_PACTL_LOG"
|
||||
ipc call sound-services-test moveApplication twoStream \
|
||||
alsa_output.pci-0000_00_1f.3.analog-stereo >/dev/null
|
||||
for _ in $(seq 1 60); do
|
||||
state="$(ipc call sound-services-test routing)"
|
||||
[[ -n "$(jq -r '.lastError' <<<"$state")" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.lastError != "" and .busy == false' >/dev/null <<<"$state" \
|
||||
|| fail "a refused move was neither reported nor finished: $state"
|
||||
|
||||
# An error is a state, not a terminal one.
|
||||
printf 'good\n' >"$PANAMA_SOUND_MOVE_MODE"
|
||||
ipc call sound-services-test moveApplication twoStream \
|
||||
alsa_output.pci-0000_00_1f.3.analog-stereo >/dev/null
|
||||
for _ in $(seq 1 60); do
|
||||
state="$(ipc call sound-services-test routing)"
|
||||
jq -e '.lastError == "" and .busy == false' >/dev/null <<<"$state" && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.lastError == "" and .busy == false' >/dev/null <<<"$state" \
|
||||
|| fail "the service never cleared the error from a move that then succeeded: $state"
|
||||
|
||||
if rg -n 'ReferenceError|TypeError|Binding loop|Unable to assign|Cannot assign' "$shell_log"; then
|
||||
fail 'sound services harness emitted a QML runtime warning'
|
||||
fi
|
||||
|
||||
# ── Static ───────────────────────────────────────────────────────────────────
|
||||
rg -Fq 'pragma Singleton' "$service" || fail 'SoundRouting is not a singleton'
|
||||
|
||||
# There is more than one way to put an application back on the system default
|
||||
# and they do not behave the same. Whichever one is here, the next person has
|
||||
# to be able to find out why, without running it.
|
||||
python3 - "$service" <<'PY' || fail 'routeToDefault does not say which mechanism it uses'
|
||||
import re
|
||||
import sys
|
||||
|
||||
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
|
||||
index = next((i for i, line in enumerate(lines)
|
||||
if re.search(r'\bfunction\s+routeToDefault\b', line)), None)
|
||||
if index is None:
|
||||
raise SystemExit("routeToDefault is missing")
|
||||
above = [line.strip() for line in lines[max(0, index - 12):index]]
|
||||
if not any(line.startswith("//") for line in above):
|
||||
raise SystemExit("routeToDefault has no comment explaining its mechanism")
|
||||
PY
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'sound routing contract: PASS\n'
|
||||
Reference in New Issue
Block a user