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: "" // ── 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; root.playingChannel = channelName; player.command = ["pw-play", "--target", target, file]; player.running = true; } 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"; } } }