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") || "" // Assigning `running = true` to a Process that is already running is a // no-op, not a queue, so a refresh that arrived mid-read was simply lost -- // and a profile switch's own re-read is exactly the refresh most likely to // land on top of one, leaving the list showing the profile the card no // longer has. Remembered here and re-run from lister.onExited instead. property bool refreshPending: false function refresh(): void { if (lister.running) { root.refreshPending = true; return; } root.refreshPending = false; 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; } // Deferred a turn so this listing's stdout is parsed before the // next one starts filling the same collector. if (root.refreshPending) Qt.callLater(() => root.refresh()); } } Process { id: writer onExited: (code, status) => { root.lastError = code === 0 ? "" : "That device profile could not be applied."; root.refresh(); } } }