From 6f5b9f4222fa53ff36a452886d3ed3380b807301 Mon Sep 17 00:00:00 2001 From: Lemmy Date: Fri, 6 Mar 2026 23:19:04 -0500 Subject: [PATCH] feat(spectrum): new cava free service --- Services/Media/SpectrumService.qml | 191 ++------------------ Widgets/AudioSpectrum/NLinearSpectrum.qml | 2 +- Widgets/AudioSpectrum/NMirroredSpectrum.qml | 2 +- Widgets/AudioSpectrum/NWaveSpectrum.qml | 2 +- 4 files changed, 18 insertions(+), 179 deletions(-) diff --git a/Services/Media/SpectrumService.qml b/Services/Media/SpectrumService.qml index 00972a9e4..0b5ceddae 100644 --- a/Services/Media/SpectrumService.qml +++ b/Services/Media/SpectrumService.qml @@ -2,215 +2,54 @@ pragma Singleton import QtQuick import Quickshell -import Quickshell.Io +import Quickshell.Services.Pipewire import qs.Commons import qs.Services.UI Singleton { id: root - // Register a component that needs audio data, call this when a visualizer becomes active. - // Pass a unique identifier (e.g., "lockscreen", "controlcenter:screen1", "plugin:fancy-audiovisualizer") function registerComponent(componentId) { root.registeredComponents[componentId] = true; root.registeredComponents = Object.assign({}, root.registeredComponents); Logger.d("Spectrum", "Component registered:", componentId, "- total:", root.registeredCount); } - // Unregister a component when it no longer needs audio data. function unregisterComponent(componentId) { delete root.registeredComponents[componentId]; root.registeredComponents = Object.assign({}, root.registeredComponents); Logger.d("Spectrum", "Component unregistered:", componentId, "- total:", root.registeredCount); } - // Check if a component is registered function isRegistered(componentId) { return root.registeredComponents[componentId] === true; } - // Component registration - any component needing audio data registers here property var registeredComponents: ({}) readonly property int registeredCount: Object.keys(registeredComponents).length property bool shouldRun: registeredCount > 0 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; } } } diff --git a/Widgets/AudioSpectrum/NLinearSpectrum.qml b/Widgets/AudioSpectrum/NLinearSpectrum.qml index 878dd8169..499d30313 100644 --- a/Widgets/AudioSpectrum/NLinearSpectrum.qml +++ b/Widgets/AudioSpectrum/NLinearSpectrum.qml @@ -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 diff --git a/Widgets/AudioSpectrum/NMirroredSpectrum.qml b/Widgets/AudioSpectrum/NMirroredSpectrum.qml index 4da22d6d2..68f2eeb1f 100644 --- a/Widgets/AudioSpectrum/NMirroredSpectrum.qml +++ b/Widgets/AudioSpectrum/NMirroredSpectrum.qml @@ -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 diff --git a/Widgets/AudioSpectrum/NWaveSpectrum.qml b/Widgets/AudioSpectrum/NWaveSpectrum.qml index 5ae371452..08eda2125 100644 --- a/Widgets/AudioSpectrum/NWaveSpectrum.qml +++ b/Widgets/AudioSpectrum/NWaveSpectrum.qml @@ -20,7 +20,7 @@ Item { // Reactive path that updates when values change readonly property string svgPath: { - if (!values || !Array.isArray(values) || values.length === 0) { + if (!values || values.length === undefined || values.length === 0) { return _safeFallbackPath; }