206 lines
8.6 KiB
QML
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";
|
|
}
|
|
}
|
|
}
|