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:
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user