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

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 11:39:16 -04:00
parent 9bc68ba358
commit b58371bb35
38 changed files with 3884 additions and 213 deletions
@@ -27,6 +27,16 @@ Singleton {
readonly property var applications: AudioStreams.group(
root.playbackStreams, PwNodeType.AudioOutStream)
// The capture side of the same story: which applications currently hold the
// microphone open. Same grouping rules as playback, so an application that
// records on three streams reads as one entry with one mute.
readonly property var captureStreams: Pipewire.nodes.values.filter(node =>
node.ready && node.audio
&& (node.type & PwNodeType.AudioInStream) === PwNodeType.AudioInStream)
readonly property var captureApplications: AudioStreams.group(
root.captureStreams, PwNodeType.AudioInStream)
function nodes(output: bool): var {
return output ? root.outputs : root.inputs;
}
@@ -58,8 +68,11 @@ Singleton {
return AudioStreams.muted(application);
}
function setApplicationVolume(application: var, value: real): bool {
return AudioStreams.setVolume(application, value);
// `max` is the clamp ceiling; omitting it means 1, and anything that is not
// a positive number is treated as omitted. Callers that honor the
// over-amplification setting pass 1.5.
function setApplicationVolume(application: var, value: real, max: real): bool {
return AudioStreams.setVolume(application, value, max);
}
function setApplicationMuted(application: var, muted: bool): bool {
+10 -3
View File
@@ -54,9 +54,16 @@ function muted(application) {
return nodes.length > 0 && nodes.every(node => node.audio.muted === true);
}
function setVolume(application, value) {
const next = Math.max(0, Math.min(1, Number(value)));
if (!Number.isFinite(next)) return false;
// `max` is the ceiling the value is clamped to, defaulting to 1. It is a
// parameter rather than a constant so over-amplification (1.5) stays a caller's
// decision: this file is deliberately free of any Settings dependency so it can
// be reasoned about -- and tested -- as plain JavaScript.
function setVolume(application, value, max) {
const requested = Number(value);
if (!Number.isFinite(requested)) return false;
const ceiling = Number(max);
const bound = Number.isFinite(ceiling) && ceiling > 0 ? ceiling : 1;
const next = Math.max(0, Math.min(bound, requested));
const nodes = audioNodes(application);
for (const node of nodes) {
node.audio.muted = false;
+35
View File
@@ -15,6 +15,7 @@ pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import Quickshell.Io
import Quickshell.Services.Notifications
import QtQuick
import qs.config
@@ -232,6 +233,7 @@ Singleton {
// because FocusModes.allows is false whenever no mode is active.
if (!root.doNotDisturb || FocusModes.allows(root.notificationAppId(notification))) {
root.popups = [notification].concat(root.popups);
root.playBell(notification);
} else if (notification.transient) {
// Never shown, and (being transient) never filed in history
// either — nothing will otherwise dismiss() it, so schedule
@@ -240,6 +242,39 @@ Singleton {
}
}
// ── The chime ───────────────────────────────────────────────────────────
//
// A notification you can hear. GTK applications get this from libcanberra
// for free; Panama's own popups had no sound at all, so a notification that
// arrived while you were looking elsewhere simply did not happen. Same
// theme, same desktop preference: one switch covers the whole session
// rather than leaving Panama as the one thing that stays quiet -- or the
// one thing that will not shut up.
//
// Throttled to one bell a second. A burst -- a chat catching up after a
// suspend, ten build jobs finishing together -- would otherwise stack a
// dozen overlapping bells, which is a noise rather than a notification.
property real lastBellAt: 0
function playBell(notification: var): void {
if (!SoundFeedback.eventSounds)
return;
// Low urgency is the "you did not need to know this" tier -- battery
// reaching full, a sync completing. It stays silent by design.
if (notification.urgency === NotificationUrgency.Low)
return;
const now = Date.now();
if (bell.running || now - root.lastBellAt < 1000)
return;
root.lastBellAt = now;
bell.command = SoundFeedback.bellCommand;
bell.running = true;
}
Process { id: bell }
// Runs a DND-hidden transient notification through the same lifetime it
// would have gotten as a visible popup (Toast.qml's countdown), just
// without ever showing it, so it still gets released instead of staying
@@ -59,7 +59,8 @@ Singleton {
"notifications": "notifications",
"capture": "screen-intelligence",
"gaming": "gaming",
"search": "applications"
"search": "applications",
"sound": "sound"
})
// Settings that are real but have no schema entry, because the system owns
@@ -126,6 +127,13 @@ Singleton {
{ label: "Input volume", detail: "Choose the microphone and its level", page: "sound" },
{ label: "Per-application volume", detail: "Set the level of each application separately", page: "sound" },
{ label: "Event sounds", detail: "Play alerts and interface event sounds", page: "sound" },
{ label: "Balance", detail: "Shift the output between the left and right speaker", page: "sound" },
{ label: "Speaker test", detail: "Play a tone through each channel to find which speaker is which", page: "sound" },
{ label: "Microphone test", detail: "Record a few seconds and play it straight back", page: "sound" },
{ label: "Alert sound", detail: "The sound theme alerts and Panama's notification chime use", page: "sound" },
{ label: "Applications using the microphone", detail: "What is listening right now, and muting it", page: "sound" },
{ label: "Move an application's audio", detail: "Send one application to a different output device", page: "sound" },
{ label: "Device profiles", detail: "Switch a sound card between stereo, surround, and headset modes", page: "sound" },
{ label: "Saved passwords", detail: "The login keyring and what is stored in it", page: "privacy" },
{ label: "Camera and microphone", detail: "Which applications may use them", page: "privacy" },
{ label: "Screen sharing", detail: "Which applications may capture the screen", page: "privacy" },
@@ -0,0 +1,162 @@
pragma Singleton
// Native device profiles -- the sound card's own idea of what it can be.
//
// A card is one piece of hardware; a profile is one wiring of it. The rear
// Realtek can be analog stereo out, or S/PDIF out, or duplex with the line-in
// live, but only one at a time, and switching costs the sinks and sources the
// old profile provided. That choice is not a PipeWire node property, so
// Quickshell's PipeWire bindings cannot see it at all -- which is why this file
// shells out where AudioDevices must not.
//
// `pactl -f json list cards` is the read side (verified against pactl 17 /
// pipewire-pulse, which prints profiles and ports as objects keyed by name, not
// as arrays). `pactl set-card-profile` is the write side. Both talk to
// pipewire-pulse, which owns card state -- so nothing here races the PipeWire
// node objects AudioDevices holds.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
// [{ name, description, activeProfile,
// profiles: [{ name, description, available }], portHint }]
property var cards: []
property string lastError: ""
readonly property bool busy: lister.running || writer.running
// True once a listing has completed, however it went, so the UI can tell
// "not looked yet" apart from "no cards on this machine".
property bool loaded: false
// A test seam, matching scripts/panama-brightness: point this at a file
// holding canned `pactl -f json list cards` output and the service parses
// that instead of the live daemon.
readonly property string fixturePath: Quickshell.env("PANAMA_SOUND_CARDS_FIXTURE") || ""
function refresh(): void {
if (lister.running)
return;
lister.command = root.fixturePath !== ""
? ["cat", root.fixturePath]
: ["pactl", "-f", "json", "list", "cards"];
lister.running = true;
}
function cardFor(name: string): var {
return root.cards.find(card => card.name === name) ?? null;
}
// Switching profile is immediate and lossy: the sinks the old profile
// provided disappear. The list is re-read afterwards rather than assumed,
// because a card may refuse a profile its ports cannot currently support.
function setProfile(cardName: string, profileName: string): void {
const card = String(cardName ?? "").trim();
const profile = String(profileName ?? "").trim();
if (card === "" || profile === "" || writer.running)
return;
writer.command = ["pactl", "set-card-profile", card, profile];
writer.running = true;
}
// The most useful single line about where sound physically comes out.
//
// Port availability is the only part of a card's state that answers "is
// anything plugged in", and it is per-port, so the highest-priority
// available port is the one worth naming. A card whose ports all report
// "availability unknown" -- USB devices, mostly -- gets no line rather than
// a guess, because "unknown" is not "disconnected".
function portHintFor(ports: var): string {
let best = null;
let anyKnown = false;
for (const key in ports ?? {}) {
const port = ports[key];
const availability = String(port?.availability ?? "");
if (availability === "available" || availability === "not available")
anyKnown = true;
if (availability !== "available")
continue;
if (!best || Number(port.priority ?? 0) > Number(best.priority ?? 0))
best = port;
}
if (best)
return `${String(best.description ?? "Port").trim()} connected`;
return anyKnown ? "No port connected" : "";
}
function parse(text: string): void {
let parsed;
try {
parsed = JSON.parse(text);
} catch (error) {
root.cards = [];
root.lastError = "Device profiles could not be read.";
root.loaded = true;
return;
}
if (!Array.isArray(parsed)) {
root.cards = [];
root.lastError = "Device profiles could not be read.";
root.loaded = true;
return;
}
root.cards = parsed.map(card => {
const properties = card.properties ?? {};
const profiles = [];
for (const key in card.profiles ?? {}) {
const profile = card.profiles[key];
profiles.push({
name: key,
description: String(profile?.description ?? key),
available: profile?.available !== false,
priority: Number(profile?.priority ?? 0)
});
}
// pactl prints profiles in an object, so their order is whatever
// the daemon happened to build. Priority is the order PulseAudio
// and GNOME both present them in, and it puts "Off" last for free.
profiles.sort((a, b) => b.priority - a.priority);
return {
name: String(card.name ?? ""),
description: String(properties["device.description"]
?? properties["device.nick"]
?? card.name
?? "Sound card"),
activeProfile: String(card.active_profile ?? ""),
profiles: profiles,
portHint: root.portHintFor(card.ports)
};
}).filter(card => card.name !== "");
root.lastError = "";
root.loaded = true;
}
Process {
id: lister
stdout: StdioCollector {
onStreamFinished: root.parse(this.text)
}
onExited: (code, status) => {
if (code !== 0) {
root.cards = [];
root.lastError = "Device profiles could not be read.";
root.loaded = true;
}
}
}
Process {
id: writer
onExited: (code, status) => {
root.lastError = code === 0 ? "" : "That device profile could not be applied.";
root.refresh();
}
}
}
@@ -0,0 +1,189 @@
pragma Singleton
// What you *asked* for, as opposed to what you got.
//
// PipeWire keeps two different answers to "which is the default output". The
// effective one -- the sink audio is actually reaching right now -- is what
// Quickshell's `Pipewire.defaultAudioSink` exposes. The configured one is the
// device you last chose, remembered by name, and it survives that device being
// switched off, unpaired, or carried into another room.
//
// The two disagree constantly, and the disagreement is the whole story a sound
// page should be telling. Choose the Bluetooth headphones, walk away, and the
// configured default stays those headphones while the effective default falls
// back to the speakers. A list that only renders present devices shows the
// speakers selected and no trace of the headphones, which reads as "Panama
// forgot" rather than "they are out of range".
//
// So this service reads the configured names and says, plainly, when one of
// them maps to nothing that is currently here. The UI renders that as a ghost
// row: visible, dimmed, not selectable, labelled "Returns when connected".
//
// Read through pw-metadata rather than wpctl. `wpctl status` prints the
// *effective* defaults only -- the configured name appears nowhere in its
// output -- so it cannot answer the question this file exists to answer.
// `pw-metadata -n default 0` prints both, exits immediately, and mutates
// nothing.
//
// Quickshell's `Pipewire.preferredDefaultAudioSink` is the same configured
// value, but typed as a node pointer, so it reads null in exactly the case that
// matters: the configured device is not here, and there is no node to point at.
// A name survives where a pointer cannot, which is why the string is read
// directly.
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire
import QtQuick
Singleton {
id: root
// The names PipeWire remembers as chosen. Empty until the first read
// lands, and empty on a session that has never had a default set by hand.
property string configuredSinkName: ""
property string configuredSourceName: ""
// The names audio is actually reaching. Kept alongside the configured pair
// purely so the two can be compared without a second source of truth.
property string effectiveSinkName: ""
property string effectiveSourceName: ""
property string lastError: ""
readonly property bool busy: reader.running
// True once a read has completed, however it went -- so the UI can tell
// "not looked yet" apart from "looked, and nothing is configured".
property bool loaded: false
// A test seam, matching scripts/panama-brightness: point this at a file
// holding canned pw-metadata output and the service parses that instead of
// talking to the live graph.
readonly property string fixturePath: Quickshell.env("PANAMA_SOUND_DEFAULTS_FIXTURE") || ""
readonly property var presentNames: {
const names = {};
for (const node of Pipewire.nodes.values) {
if (node && !node.isStream && node.name)
names[String(node.name)] = true;
}
return names;
}
// The configured device that is not here, or null. This is exactly what the
// ghost row renders; `null` means every configured default is present and
// the list has nothing extra to say.
readonly property var absentSink: root.configuredSinkName !== ""
&& root.presentNames[root.configuredSinkName] !== true
? ({ name: root.configuredSinkName, label: root.label(root.configuredSinkName) })
: null
readonly property var absentSource: root.configuredSourceName !== ""
&& root.presentNames[root.configuredSourceName] !== true
? ({ name: root.configuredSourceName, label: root.label(root.configuredSourceName) })
: null
function absent(output: bool): var {
return output ? root.absentSink : root.absentSource;
}
function refresh(): void {
if (reader.running)
return;
reader.command = root.fixturePath !== ""
? ["cat", root.fixturePath]
: ["pw-metadata", "-n", "default", "0"];
reader.running = true;
}
// A node name turned into something a person can read.
//
// An absent device has no description to borrow -- descriptions live on the
// node, and the node is gone -- so the stored name is all there is. These
// names are structured, though, and the structure carries the useful part:
// the Bluetooth address, the AirPlay speaker's hostname, the USB device's
// product string. Anything this does not recognise is tidied rather than
// guessed at, because a wrong name is worse than an ugly one.
function label(name: string): string {
const raw = String(name ?? "").trim();
if (raw === "")
return "";
// bluez_output.74_15_F5_13_A4_28.1 -- nothing but the address.
const bluetooth = raw.match(/^bluez_[a-z]+\.([0-9A-Fa-f]{2}(?:_[0-9A-Fa-f]{2}){5})/);
if (bluetooth)
return `Bluetooth device (${bluetooth[1].replace(/_/g, ":").toUpperCase()})`;
// raop_sink.Living-Room.local.192.168.1.162.7000 -- the mDNS hostname.
const airplay = raw.match(/^raop_[a-z]+\.(.+?)\.local\b/);
if (airplay)
return airplay[1].replace(/[-_]+/g, " ").trim();
// alsa_output.usb-Generic_USB_Audio-00.analog-stereo -- the udev bus id
// carries the product string, with a two-digit interface number glued
// on the end that means nothing to anyone.
const usb = raw.match(/^alsa_[a-z]+\.usb-(.+?)(?:-[0-9]{2})?\.[^.]*$/);
if (usb)
return usb[1].replace(/_+/g, " ").replace(/\s+/g, " ").trim();
const tidied = raw.replace(/^[a-z0-9]+_[a-z]+\./, "")
.replace(/[._]+/g, " ")
.replace(/\s+/g, " ")
.trim();
return tidied || raw;
}
// pw-metadata prints one line per key:
// update: id:0 key:'default.audio.sink' value:'{"name":"..."}' type:'...'
// The value is JSON, and carries `name` -- and, on some setups, a
// `description` PipeWire stored alongside it, which is a better label than
// anything derivable from the name.
function parse(text: string): void {
const found = {};
for (const line of String(text ?? "").split("\n")) {
const matched = line.match(/key:'([^']+)'\s+value:'(.*)'\s+type:'/);
if (!matched)
continue;
try {
const value = JSON.parse(matched[2]);
const name = String(value?.name ?? "").trim();
if (name !== "")
found[matched[1]] = name;
} catch (error) {
// A key whose value is not JSON is not our key. Skip it rather
// than failing the whole read.
}
}
root.configuredSinkName = found["default.configured.audio.sink"] ?? "";
root.configuredSourceName = found["default.configured.audio.source"] ?? "";
root.effectiveSinkName = found["default.audio.sink"] ?? "";
root.effectiveSourceName = found["default.audio.source"] ?? "";
root.loaded = true;
}
Process {
id: reader
stdout: StdioCollector {
onStreamFinished: root.parse(this.text)
}
onExited: (code, status) => {
root.lastError = code === 0
? ""
: "PipeWire's remembered default devices could not be read.";
if (code !== 0)
root.loaded = true;
}
}
// The effective default moving is the signal that the configured one may
// now be unreachable (or reachable again). Re-reading on that change is
// what keeps the ghost row honest without polling.
Connections {
target: Pipewire
function onDefaultAudioSinkChanged(): void { root.refresh(); }
function onDefaultAudioSourceChanged(): void { root.refresh(); }
}
Component.onCompleted: root.refresh()
}
@@ -1,8 +1,14 @@
pragma Singleton
// GNOME and GTK applications already honor these desktop sound preferences.
// Panama controls the same durable keys so moving between sessions does not
// create two competing notions of whether event feedback is enabled.
// GNOME and GTK applications already honor these desktop sound preferences --
// whether event feedback plays, whether typing clicks, and which sound theme
// those samples come from. Panama controls the same durable keys so moving
// between sessions does not create two competing notions of any of it, and
// plays its own notification chime out of the same theme (see Notifs.qml).
//
// There is deliberately no alert *volume* here: the desktop sound theme has no
// volume channel of its own, and a slider that changed nothing would be worse
// than no slider at all.
import Quickshell
import Quickshell.Io
@@ -13,9 +19,58 @@ Singleton {
property bool eventSounds: true
property bool inputFeedback: false
// The XDG sound theme, by directory name. "freedesktop" is the one every
// distribution ships and the fallback everything else is layered over.
property string soundTheme: "freedesktop"
property string lastError: ""
readonly property bool busy: eventRead.running || inputRead.running
|| eventWrite.running || inputWrite.running
|| themeRead.running || eventWrite.running || inputWrite.running
|| themeWrite.running
// [{ directory, name }] -- the directory is what gsettings stores, the name
// is what the theme calls itself in its index.theme.
property var themes: [{ directory: "freedesktop", name: "Default" }]
// ── The alert sound ─────────────────────────────────────────────────────
//
// A theme is a directory of samples with a fallback chain, not a single
// file, and a theme that overrides only a handful of sounds is normal. So
// the bell is resolved as a list of candidates in preference order --
// user-installed theme, system theme, freedesktop -- and the first one that
// exists is played. Resolving it in the shell rather than here keeps this
// free of file probing on a hot path.
readonly property string homeDir: Quickshell.env("HOME") || ""
readonly property var bellCandidates: [
root.homeDir !== ""
? `${root.homeDir}/.local/share/sounds/${root.soundTheme}/stereo/bell.oga` : "",
`/usr/share/sounds/${root.soundTheme}/stereo/bell.oga`,
"/usr/share/sounds/freedesktop/stereo/bell.oga"
].filter((path, index, all) => path !== "" && all.indexOf(path) === index)
// The argv that plays the current theme's bell once, or nothing at all if
// no candidate exists. Shared with Notifs.qml, which plays the same bell
// for Panama's own notification popups.
readonly property var bellCommand: ["sh", "-c",
'for candidate in "$@"; do [ -f "$candidate" ] && exec pw-play "$candidate"; done; exit 0',
"qs-sound-feedback"].concat(root.bellCandidates)
function previewAlert(): void {
if (preview.running)
return;
preview.command = root.bellCommand;
preview.running = true;
}
function setSoundTheme(name: string): void {
const theme = String(name ?? "").trim();
if (theme === "")
return;
root.soundTheme = theme;
root._writeSoundTheme();
}
function parsedBoolean(text: string, fallback: bool): bool {
const value = text.trim();
@@ -31,6 +86,10 @@ Singleton {
eventRead.running = true;
if (!inputRead.running)
inputRead.running = true;
if (!themeRead.running)
themeRead.running = true;
if (!themeScan.running)
themeScan.running = true;
}
function setEventSounds(enabled: bool): void {
@@ -64,6 +123,20 @@ Singleton {
inputWrite.running = true;
}
function _writeSoundTheme(): void {
if (themeWrite.running)
return;
themeWrite.writtenValue = root.soundTheme;
themeWrite.command = ["gsettings", "set", "org.gnome.desktop.sound", "theme-name", root.soundTheme];
themeWrite.running = true;
}
// gsettings prints strings quoted: 'freedesktop'.
function parsedString(text: string, fallback: string): string {
const value = text.trim().replace(/^'(.*)'$/, "$1");
return value === "" ? fallback : value;
}
Process {
id: eventRead
command: ["gsettings", "get", "org.gnome.desktop.sound", "event-sounds"]
@@ -118,5 +191,70 @@ Singleton {
}
}
Process {
id: themeRead
command: ["gsettings", "get", "org.gnome.desktop.sound", "theme-name"]
stdout: StdioCollector {
onStreamFinished: root.soundTheme = root.parsedString(this.text, root.soundTheme)
}
onExited: (code, status) => {
if (code !== 0)
root.lastError = "The alert sound theme could not be read.";
}
}
Process {
id: themeWrite
property string writtenValue: "freedesktop"
onExited: (code, status) => {
if (code !== 0) {
root.lastError = "The alert sound theme could not be changed.";
root.refresh();
} else {
root.lastError = "";
}
if (root.soundTheme !== themeWrite.writtenValue)
root._writeSoundTheme();
}
}
// Installed themes, from the index.theme every XDG sound theme must carry.
// Emitted as directory<TAB>name so the pair cannot drift apart; a theme
// whose index.theme has no Name= falls back to its directory rather than
// being dropped, because it is still selectable and still works.
Process {
id: themeScan
command: ["sh", "-c", `
for theme in "$HOME"/.local/share/sounds/*/index.theme /usr/share/sounds/*/index.theme; do
[ -f "$theme" ] || continue
directory=$(basename "$(dirname "$theme")")
name=$(sed -n 's/^Name=//p' "$theme" | head -n1)
[ -n "$name" ] || name=$directory
printf '%s\\t%s\\n' "$directory" "$name"
done
`]
stdout: StdioCollector {
onStreamFinished: {
const found = [];
const seen = {};
for (const line of String(this.text).split("\n")) {
const parts = line.split("\t");
const directory = String(parts[0] ?? "").trim();
if (directory === "" || seen[directory])
continue;
seen[directory] = true;
found.push({ directory, name: String(parts[1] ?? "").trim() || directory });
}
// Never hand the UI an empty picker: freedesktop is the theme
// every fallback chain ends at, present or not.
if (!seen["freedesktop"])
found.push({ directory: "freedesktop", name: "Default" });
root.themes = found;
}
}
}
Process { id: preview }
Component.onCompleted: root.refresh()
}
@@ -0,0 +1,160 @@
pragma Singleton
// Sending one application's audio to a different output.
//
// PipeWire routes a playback stream either by following the system default or
// by a per-stream target the session manager remembers. Quickshell's PipeWire
// bindings expose the graph but not that routing decision, so moving a stream
// means shelling out -- which is why this lives beside AudioDevices rather than
// inside it, on the same reasoning as SoundTest.
//
// ── Reading: native ─────────────────────────────────────────────────────────
// `currentSinkFor` walks `Pipewire.linkGroups`, which already says which sink a
// stream's links land on. Nothing is shelled out to read.
//
// It reports "" -- follows the default -- whenever the stream is linked to the
// sink that is currently the default. That is a deliberate simplification: an
// application explicitly pinned to the device that also happens to be the
// default is indistinguishable from one that is merely following it, and the
// difference is inaudible for as long as it lasts. The moment the default moves
// away, the pinned stream stays put and starts reporting its real sink, which
// is the case the picker actually needs to get right. The alternative --
// polling `pw-metadata` for per-node target keys -- buys a distinction nobody
// can hear, at the cost of a timer.
//
// ── Writing: two different mechanisms, deliberately ─────────────────────────
// `moveApplication` uses `pactl move-sink-input <serial> <sink>`. The serial is
// `object.serial`, which is exactly what pipewire-pulse presents as a PulseAudio
// index (verified: `pactl -f json list` prints `index` equal to the object's
// `object.serial`), so no id translation is needed.
//
// `routeToDefault` cannot use the same call, because moving a stream *to* the
// default sink pins it there -- it would stop following, and quietly stay behind
// the next time the default changed. Pinning is stored as a `target.object` key
// on the stream's node id in PipeWire's "default" metadata, so releasing it is
// deleting that key: `pw-metadata -n default -d <node-id> target.object`. The
// session manager sees the metadata change and re-links the stream to whatever
// the default is now. The legacy `target.node` key is deleted alongside it,
// because streams pinned by older tooling carry that one instead.
//
// Note the id asymmetry: pactl speaks object serials, pw-metadata speaks node
// ids. They are different numbers for the same stream.
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire
import QtQuick
Singleton {
id: root
property string lastError: ""
readonly property bool busy: mover.running || root.queue.length > 0
// Pending commands, run one at a time. An application on three streams is
// three calls, and letting them overlap means three processes racing for
// the same metadata.
property var queue: []
function streamSerial(node: var): string {
const serial = String(node?.properties?.["object.serial"] ?? "").trim();
return serial !== "" ? serial : String(node?.id ?? "");
}
// Name of the sink this application's audio is reaching, or "" when it is
// simply following the system default. See the note above on why those two
// answers merge while the default *is* that sink.
function currentSinkFor(group: var): string {
const nodes = group?.nodes ?? [];
if (nodes.length === 0)
return "";
const stream = nodes[0];
const defaultSink = Pipewire.defaultAudioSink;
for (const linkGroup of Pipewire.linkGroups.values) {
if (linkGroup?.source !== stream || !linkGroup?.target)
continue;
if (defaultSink && linkGroup.target === defaultSink)
return "";
return String(linkGroup.target.name ?? "");
}
return "";
}
// Pin every stream of this application to one sink.
function moveApplication(group: var, sinkName: string): void {
const sink = String(sinkName ?? "").trim();
const nodes = group?.nodes ?? [];
if (sink === "" || nodes.length === 0)
return;
const commands = [];
for (const node of nodes) {
const serial = root.streamSerial(node);
if (serial !== "")
commands.push(["pactl", "move-sink-input", serial, sink]);
}
root.enqueue(commands, "That application's audio could not be moved.");
}
// Release the pin, so the application follows the system default again.
//
// Mechanism: delete the stream's routing key from PipeWire's "default"
// metadata -- `pw-metadata -n default -d <node-id> target.object`, plus the
// legacy `target.node` for streams older tooling pinned. The session
// manager re-links on the metadata change.
//
// Deliberately NOT `pactl move-sink-input <serial> <default sink>`: that
// writes the pin rather than clearing it, so the application would sit on
// today's default forever and quietly stop following tomorrow's.
//
// Note the id asymmetry -- pw-metadata takes the node id, pactl takes the
// object serial, and they are different numbers for the same stream.
function routeToDefault(group: var): void {
const nodes = group?.nodes ?? [];
if (nodes.length === 0)
return;
const commands = [];
for (const node of nodes) {
const id = String(node?.id ?? "").trim();
if (id === "")
continue;
commands.push(["pw-metadata", "-n", "default", "-d", id, "target.object"]);
commands.push(["pw-metadata", "-n", "default", "-d", id, "target.node"]);
}
root.enqueue(commands, "That application could not be returned to the default output.");
}
function enqueue(commands: var, failureMessage: string): void {
if (commands.length === 0)
return;
root.lastError = "";
root.queue = root.queue.concat(commands.map(command => ({
command: command,
failureMessage: failureMessage
})));
root.pump();
}
property string pendingFailure: ""
function pump(): void {
if (mover.running || root.queue.length === 0)
return;
const next = root.queue[0];
root.queue = root.queue.slice(1);
root.pendingFailure = next.failureMessage;
mover.command = next.command;
mover.running = true;
}
Process {
id: mover
onExited: (code, status) => {
if (code !== 0)
root.lastError = root.pendingFailure;
root.pump();
}
}
}
+174 -9
View File
@@ -1,40 +1,205 @@
pragma Singleton
// Playing a short sound out of one chosen output.
// Playing a short sound out of one chosen output, and hearing your own
// microphone back.
//
// Deliberately not part of AudioDevices, which owns device state through the
// Quickshell PipeWire bindings and must not shell out -- doing so there would
// race the service that owns those same objects. This spawns a short-lived
// playback client instead: it creates its own stream and mutates no device, so
// there is nothing for it to race.
// race the service that owns those same objects. This spawns short-lived
// playback and capture clients instead: they create their own streams and
// mutate no device, so there is nothing for them to race.
//
// It exists because nine outputs named after their chipsets cannot be told
// apart by reading. The only way to know which is which is to hear one.
// apart by reading. The only way to know which is which is to hear one -- and
// on a device with more than two channels, the only way to know the rear pair
// is wired the right way round is to hear each channel on its own.
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire
import QtQuick
Singleton {
id: root
readonly property string sampleDir: "/usr/share/sounds/freedesktop/stereo"
// A short, unmistakable, front-and-centre sample that ships with the
// freedesktop sound theme, so nothing has to be bundled.
readonly property string sample:
"/usr/share/sounds/freedesktop/stereo/audio-channel-front-center.oga"
readonly property string sample: `${root.sampleDir}/audio-channel-front-center.oga`
readonly property bool playing: player.running
// The channel currently being played, "" when nothing is. Drives the lit
// chip in the channel strip.
property string playingChannel: ""
// ── Channels ────────────────────────────────────────────────────────────
//
// The freedesktop theme ships one spoken sample per channel, and the set it
// ships is the set that can be tested. A channel with no sample on disk --
// the LFE, chiefly, which has no name to speak -- is left out of the strip
// rather than given a chip that would do nothing when pressed.
readonly property var channelSamples: {
const samples = {};
samples[PwAudioChannel.Mono] = { name: "Mono", label: "Mono", file: "audio-channel-front-center" };
samples[PwAudioChannel.FrontLeft] = { name: "FrontLeft", label: "Front Left", file: "audio-channel-front-left" };
samples[PwAudioChannel.FrontRight] = { name: "FrontRight", label: "Front Right", file: "audio-channel-front-right" };
samples[PwAudioChannel.FrontCenter] = { name: "FrontCenter", label: "Center", file: "audio-channel-front-center" };
samples[PwAudioChannel.SideLeft] = { name: "SideLeft", label: "Side Left", file: "audio-channel-side-left" };
samples[PwAudioChannel.SideRight] = { name: "SideRight", label: "Side Right", file: "audio-channel-side-right" };
samples[PwAudioChannel.RearLeft] = { name: "RearLeft", label: "Rear Left", file: "audio-channel-rear-left" };
samples[PwAudioChannel.RearRight] = { name: "RearRight", label: "Rear Right", file: "audio-channel-rear-right" };
samples[PwAudioChannel.RearCenter] = { name: "RearCenter", label: "Rear Center", file: "audio-channel-rear-center" };
return samples;
}
// Ordered [{ name, label }] for one device, in the order PipeWire reports
// its channels -- which is the order they are physically wired, so the
// strip reads left to right the way the speakers stand.
function channelsFor(node: var): var {
const channels = node?.audio?.channels ?? [];
const out = [];
for (const channel of channels) {
const known = root.channelSamples[channel];
if (known && !out.some(entry => entry.name === known.name))
out.push({ name: known.name, label: known.label });
}
return out;
}
function sampleFor(channelName: string): string {
for (const key in root.channelSamples) {
const known = root.channelSamples[key];
if (known.name === channelName)
return `${root.sampleDir}/${known.file}.oga`;
}
return "";
}
// Targeted by node name taken straight from the live node. pw-play falls
// back to the default output for a target it cannot find, so a stale name
// would play out of the wrong device and look like the test had worked.
function play(node: var): void {
root.playSample(node, root.sample, "");
}
function playChannel(node: var, channelName: string): void {
const file = root.sampleFor(String(channelName ?? ""));
if (file === "")
return;
root.playSample(node, file, String(channelName));
}
function playSample(node: var, file: string, channelName: string): void {
const target = String(node?.name ?? "");
if (target === "" || player.running)
return;
player.command = ["pw-play", "--target", target, root.sample];
root.playingChannel = channelName;
player.command = ["pw-play", "--target", target, file];
player.running = true;
}
Process { id: player }
Process {
id: player
onExited: (code, status) => root.playingChannel = ""
}
// ── Microphone test ─────────────────────────────────────────────────────
//
// Record a few seconds, then play them straight back. A level meter proves
// the microphone is producing samples; only hearing yourself proves it is
// producing *you*, at a usable level, through the output you are wearing.
//
// "idle" -> "recording" -> "playing" -> "idle". One file, in the runtime
// directory, overwritten every run: recordings of the user are not
// something to leave lying around, and the runtime directory is cleared
// when the session ends.
readonly property string micTestPath:
`${Quickshell.env("XDG_RUNTIME_DIR") || "/tmp"}/panama-mic-test.wav`
property string micTestState: "idle"
readonly property int micTestSeconds: 3
readonly property int micTestRate: 48000
function startMicTest(sourceNode: var, sinkNode: var): void {
const source = String(sourceNode?.name ?? "");
if (source === "" || root.micTestState !== "idle")
return;
// pw-play needs a target too, or the playback lands on the default
// output rather than the one being looked at.
root.micTestSink = String(sinkNode?.name ?? "");
// `-n` makes pw-record stop itself after exactly this many samples and
// close the file cleanly, which is what leaves a playable WAV header
// behind. The timer below is the watchdog for a capture device that
// never produces a sample at all, where the count would never be
// reached and the test would hang in "recording" forever.
recorder.command = [
"pw-record", "--target", source,
"--rate", String(root.micTestRate),
"-n", String(root.micTestRate * root.micTestSeconds),
root.micTestPath
];
root.micTestState = "recording";
recorder.running = true;
micTestWatchdog.restart();
}
function cancelMicTest(): void {
micTestWatchdog.stop();
// Only arm the cancelled flag if something is actually going to exit
// and read it -- otherwise it would survive to poison the next run.
root.micTestCancelled = recorder.running || playback.running;
root.micTestState = "idle";
if (recorder.running)
recorder.signal(15);
if (playback.running)
playback.signal(15);
}
property string micTestSink: ""
property bool micTestCancelled: false
Timer {
id: micTestWatchdog
// Comfortably past the sample count, so it only ever fires for a
// capture that has stalled rather than one that is merely slow.
interval: (root.micTestSeconds + 3) * 1000
onTriggered: {
if (recorder.running)
recorder.signal(15);
}
}
Process {
id: recorder
onExited: (code, status) => {
micTestWatchdog.stop();
if (root.micTestCancelled) {
root.micTestCancelled = false;
root.micTestState = "idle";
return;
}
if (code !== 0) {
root.micTestState = "idle";
return;
}
root.micTestState = "playing";
playback.command = root.micTestSink !== ""
? ["pw-play", "--target", root.micTestSink, root.micTestPath]
: ["pw-play", root.micTestPath];
playback.running = true;
}
}
Process {
id: playback
onExited: (code, status) => {
root.micTestCancelled = false;
root.micTestState = "idle";
}
}
}