feat(audio-spectrum): replaced cava process by our pipewire implementation via noctalia-qs

This commit is contained in:
Lemmy
2026-03-14 09:29:35 -04:00
4 changed files with 18 additions and 174 deletions
+15 -171
View File
@@ -2,7 +2,7 @@ pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire
import qs.Commons
import qs.Services.UI
@@ -36,181 +36,25 @@ Singleton {
property var values: []
property int barsCount: 32
// Idle detection to reduce GPU usage when there's no audio
property bool isIdle: true
property int idleFrameCount: 0
readonly property int idleThreshold: 30 // Frames of silence before considered idle (0.5s at 60fps)
// Crash tracking for auto-restart
property int _crashCount: 0
property int _maxCrashes: 5
PwAudioSpectrum {
id: spectrum
node: Pipewire.defaultAudioSink
enabled: root.shouldRun
barCount: root.barsCount
frameRate: Settings.data.audio.spectrumFrameRate
lowerCutoff: 50
upperCutoff: 12000
noiseReduction: 0.77
smoothing: true
// Pre-allocated double buffer to avoid per-frame GC pressure.
// We alternate between two arrays so the reference always changes (triggering QML bindings)
// without allocating a new array on every audio frame.
property var _buf0: new Array(barsCount).fill(0)
property var _buf1: new Array(barsCount).fill(0)
property bool _bufToggle: false
// Simple config
property var config: ({
"general": {
"bars": barsCount,
"framerate": Settings.data.audio.spectrumFrameRate,
"autosens": 1,
"sensitivity": 100,
"lower_cutoff_freq": 50,
"higher_cutoff_freq": 12000
},
"smoothing": {
"monstercat": 1,
"noise_reduction"// Enable monstercat smoothing for less jittery animation
: 77
},
"output": {
"method": "raw",
"data_format": "ascii",
"ascii_max_range": 100,
"bit_format": "8bit",
"channels": "mono",
"mono_option": "average"
}
})
// Manage process lifecycle imperatively to avoid broken bindings.
// A declarative `running: shouldRun` binding would be destroyed when
// the restart timer or the Process itself sets `running` imperatively,
// causing cava to keep running after all components unregister.
onShouldRunChanged: {
if (shouldRun && !process.running) {
process.running = true;
} else if (!shouldRun && process.running) {
process.running = false;
onValuesChanged: {
root.values = spectrum.values;
}
}
Timer {
id: restartTimer
interval: 2000
repeat: false
onTriggered: {
if (root.shouldRun && !process.running) {
Logger.w("Spectrum", "Restarting after crash...");
process.running = true;
}
}
}
Process {
id: process
stdinEnabled: true
command: ["cava", "-p", "/dev/stdin"]
onRunningChanged: {
Logger.d("Spectrum", "Process running:", running);
}
onExited: {
stdinEnabled = true;
values = Array(barsCount).fill(0);
if (root.shouldRun) {
root._crashCount++;
if (root._crashCount <= root._maxCrashes) {
Logger.w("Spectrum", "Process exited unexpectedly, restarting in 2s... (attempt " + root._crashCount + "/" + root._maxCrashes + ")");
restartTimer.start();
} else {
Logger.e("Spectrum", "Process crashed too many times (" + root._maxCrashes + "), giving up");
}
} else {
Logger.d("Spectrum", "Process exited (no longer needed)");
root._crashCount = 0;
}
}
onStarted: {
Logger.d("Spectrum", "Process started");
for (const k in config) {
if (typeof config[k] !== "object") {
write(k + "=" + config[k] + "\n");
continue;
}
write("[" + k + "]\n");
const obj = config[k];
for (const k2 in obj) {
write(k2 + "=" + obj[k2] + "\n");
}
}
stdinEnabled = false;
values = Array(barsCount).fill(0);
}
stdout: SplitParser {
onRead: data => {
if (root._crashCount > 0)
root._crashCount = 0;
// Optimized parsing directly into pre-allocated buffer (alternating to trigger bindings)
const buffer = root._bufToggle ? root._buf0 : root._buf1;
let idx = 0;
let num = 0;
let allZero = true;
for (let i = 0, len = data.length - 1; i < len; i++) {
const c = data.charCodeAt(i);
if (c === 59) {
// semicolon
const val = num * 0.01;
buffer[idx++] = val;
if (val >= 0.01) {
allZero = false;
}
num = 0;
} else if (c >= 48 && c <= 57) {
// digit 0-9
num = num * 10 + (c - 48);
}
}
// Handle last value if no trailing semicolon
if (num > 0 || idx < root.barsCount) {
const val = num * 0.01;
buffer[idx++] = val;
if (val >= 0.01) {
allZero = false;
}
}
if (allZero) {
root.idleFrameCount++;
if (root.idleFrameCount >= root.idleThreshold) {
// We're idle - stop updating values to save GPU
if (!root.isIdle) {
root.isIdle = true;
// Set all values to 0 one final time
root.values = Array(root.barsCount).fill(0);
Logger.d("Spectrum", "Idle detected - stopped rendering");
}
// Don't update values while idle
return;
}
} else {
// Audio detected - resume updates
root.idleFrameCount = 0;
if (root.isIdle) {
root.isIdle = false;
Logger.d("Spectrum", "Audio detected - resumed rendering");
}
}
// Update values only if not idle - swap to the other buffer to trigger binding updates
if (!root.isIdle) {
root._bufToggle = !root._bufToggle;
root.values = buffer;
}
}
}
stderr: StdioCollector {
onStreamFinished: {
if (text.trim()) {
Logger.w("Spectrum", "Error", text);
}
}
onIdleChanged: {
root.isIdle = spectrum.idle;
}
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ Item {
property real minimumSignalValue: 0.05 // Default to 5% of height
// Pre compute horizontal mirroring
readonly property int valuesCount: (values && Array.isArray(values)) ? values.length : 0
readonly property int valuesCount: (values && values.length !== undefined) ? values.length : 0
readonly property int totalBars: valuesCount * 2
readonly property real barSlotSize: totalBars > 0 ? (vertical ? height : width) / totalBars : 0
readonly property bool highQuality: (Settings.data.audio.visualizerType === "low") ? false : true
+1 -1
View File
@@ -14,7 +14,7 @@ Item {
property real minimumSignalValue: 0.05 // Default to 5% of height
// Pre-compute mirroring
readonly property int valuesCount: (values && Array.isArray(values)) ? values.length : 0
readonly property int valuesCount: (values && values.length !== undefined) ? values.length : 0
readonly property int totalBars: valuesCount * 2
readonly property real barSlotSize: totalBars > 0 ? (vertical ? height : width) / totalBars : 0
readonly property bool highQuality: (Settings.data.audio.visualizerType === "low") ? false : true
+1 -1
View File
@@ -14,7 +14,7 @@ Item {
property bool showMinimumSignal: false
property real minimumSignalValue: 0.05 // Default to 5% of height
readonly property int valuesCount: (values && Array.isArray(values)) ? values.length : 0
readonly property int valuesCount: (values && values.length !== undefined) ? values.length : 0
readonly property bool hasData: valuesCount >= 2
// Data texture: one pixel per value, R channel = amplitude