Files
Panama/config/dot/quickshell/services/SoundTest.qml
T

206 lines
8.6 KiB
QML

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";
}
}
}