pragma Singleton // 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 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 -- 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: `${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: "" // Why the last test did not work, "" when it did. // // Every failure here is otherwise invisible, and invisible in the worst // possible way: this page exists to answer "is this the right speaker" and // "does this microphone work", and both questions are answered by silence. // A pw-play that is not installed, a sample file that is missing, a capture // device that never produces a sample -- all three look exactly like a dead // speaker or a dead microphone from the chair, which is the wrong answer // to the question the page was opened to ask. property string lastError: "" // ── 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 === "") { root.lastError = "No sample is installed for that channel."; return; } root.playSample(node, file, String(channelName)); } function playSample(node: var, file: string, channelName: string): void { if (player.running) return; const target = String(node?.name ?? ""); if (target === "") { root.lastError = "PipeWire has no name for that output, so nothing could be played to it."; return; } root.lastError = ""; root.playingChannel = channelName; player.command = ["pw-play", "--target", target, file]; player.running = true; } Process { id: player onExited: (code, status) => { root.playingChannel = ""; // A silent speaker and a pw-play that never ran are the same // experience and different problems. if (code !== 0) root.lastError = "The test sound could not be played — pw-play exited with code " + code + ". The freedesktop sound theme or pipewire-utils may be missing."; } } // ── 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 { if (root.micTestState !== "idle") return; const source = String(sourceNode?.name ?? ""); if (source === "") { root.lastError = "PipeWire has no name for that microphone, so nothing could be recorded from it."; return; } root.lastError = ""; root.micTestTimedOut = false; // 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 // Set by the watchdog, read by the exit handler: a recorder killed for // stalling and a recorder that failed outright both come back non-zero, // and only one of them means "this microphone produced no sound". property bool micTestTimedOut: 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) { root.micTestTimedOut = true; recorder.signal(15); } } } Process { id: recorder onExited: (code, status) => { micTestWatchdog.stop(); if (root.micTestCancelled) { root.micTestCancelled = false; root.micTestTimedOut = false; root.micTestState = "idle"; return; } if (root.micTestTimedOut) { root.micTestTimedOut = false; root.micTestState = "idle"; root.lastError = "That microphone produced no sound in " + (root.micTestSeconds + 3) + " seconds. It may be muted at the device."; return; } if (code !== 0) { root.micTestState = "idle"; root.lastError = "Nothing could be recorded — pw-record exited with code " + code + "."; 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) => { // A cancelled playback was killed on purpose; its non-zero exit is // not a failure to report. const cancelled = root.micTestCancelled; root.micTestCancelled = false; root.micTestState = "idle"; if (!cancelled && code !== 0) root.lastError = "The recording was made but could not be played back — " + "pw-play exited with code " + code + "."; } } }