Initial bass-manage Qt Quick / Kirigami control panel.
PipeWire LFE/center mix UI with live pw-cli controls and conf save/reload.
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# Build
|
||||
/build/
|
||||
/cmake-build-*/
|
||||
*.o
|
||||
*.a
|
||||
moc_*.cpp
|
||||
*_autogen/
|
||||
|
||||
# Editor / OS
|
||||
*~
|
||||
*.swp
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
*.user
|
||||
*.autosave
|
||||
31
CMakeLists.txt
Normal file
@@ -0,0 +1,31 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(bass-manage VERSION 1.0 LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
|
||||
find_package(Qt6 REQUIRED COMPONENTS Core Gui Qml Quick QuickControls2)
|
||||
|
||||
qt_add_executable(bass-manage-gui
|
||||
src/main.cpp
|
||||
src/backend.cpp
|
||||
src/backend.h
|
||||
)
|
||||
|
||||
target_compile_definitions(bass-manage-gui PRIVATE
|
||||
BASS_MANAGE_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
|
||||
)
|
||||
|
||||
target_link_libraries(bass-manage-gui PRIVATE
|
||||
Qt6::Core
|
||||
Qt6::Gui
|
||||
Qt6::Qml
|
||||
Qt6::Quick
|
||||
Qt6::QuickControls2
|
||||
)
|
||||
|
||||
include(GNUInstallDirs)
|
||||
install(TARGETS bass-manage-gui RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
|
||||
install(DIRECTORY qml icons DESTINATION ${CMAKE_INSTALL_DATADIR}/bass-manage)
|
||||
install(FILES bass-manage-gui.desktop DESTINATION ${CMAKE_INSTALL_DATADIR}/applications)
|
||||
9
bass-manage-gui.desktop
Normal file
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Bass Manage 7.1
|
||||
Comment=Live controls for PipeWire bass management
|
||||
Exec=bass-manage-gui
|
||||
Icon=bass-manage-gui
|
||||
Terminal=false
|
||||
Categories=AudioVideo;Audio;Qt;
|
||||
StartupNotify=true
|
||||
17
icons/align-center-horizontal.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M2 12h20" />
|
||||
<path d="M10 16v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-4" />
|
||||
<path d="M10 8V4a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v4" />
|
||||
<path d="M20 16v1a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-1" />
|
||||
<path d="M14 8V7c0-1.1.9-2 2-2h2a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 452 B |
18
icons/audio-lines.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M2 10v3" />
|
||||
<path d="M6 6v11" />
|
||||
<path d="M10 3v18" />
|
||||
<path d="M14 8v7" />
|
||||
<path d="M18 5v13" />
|
||||
<path d="M22 10v3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 344 B |
16
icons/refresh-cw.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
||||
<path d="M8 16H3v5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 387 B |
15
icons/save.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" />
|
||||
<path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7" />
|
||||
<path d="M7 3v4a1 1 0 0 0 1 1h7" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 412 B |
21
icons/sliders-horizontal.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10 5H3" />
|
||||
<path d="M12 19H3" />
|
||||
<path d="M14 3v4" />
|
||||
<path d="M16 17v4" />
|
||||
<path d="M21 12h-9" />
|
||||
<path d="M21 19h-5" />
|
||||
<path d="M21 5h-7" />
|
||||
<path d="M8 10v4" />
|
||||
<path d="M8 12H3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 417 B |
16
icons/speaker.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="16" height="20" x="4" y="2" rx="2" />
|
||||
<path d="M12 6h.01" />
|
||||
<circle cx="12" cy="14" r="4" />
|
||||
<path d="M12 14h.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 342 B |
15
icons/timer.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="10" x2="14" y1="2" y2="2" />
|
||||
<line x1="12" x2="15" y1="14" y2="11" />
|
||||
<circle cx="12" cy="14" r="8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
15
icons/volume-2.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z" />
|
||||
<path d="M16 9a5 5 0 0 1 0 6" />
|
||||
<path d="M19.364 18.364a9 9 0 0 0 0-12.728" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 471 B |
365
qml/Main.qml
Normal file
@@ -0,0 +1,365 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls as Controls
|
||||
import QtQuick.Layouts
|
||||
import org.kde.kirigami as Kirigami
|
||||
|
||||
Kirigami.ApplicationWindow {
|
||||
id: root
|
||||
title: "Bass Manage 7.1"
|
||||
width: 580
|
||||
height: 520
|
||||
minimumWidth: 480
|
||||
minimumHeight: 420
|
||||
pageStack.globalToolBar.style: Kirigami.ApplicationHeaderStyle.ToolBar
|
||||
|
||||
// Aggregated UI values (conf uses full map; UI links FL/FR & mains)
|
||||
property real fcFold: 0.707
|
||||
property real dry: 1.0
|
||||
property real lfeMains: 0.99
|
||||
property real lfeDisc: 0.99
|
||||
property real lfeDelay: 0.010
|
||||
property bool suppressApply: false
|
||||
|
||||
function valuesMap() {
|
||||
return {
|
||||
"fl_dry": dry,
|
||||
"fr_dry": dry,
|
||||
"fc_to_fl": fcFold,
|
||||
"fc_to_fr": fcFold,
|
||||
"lfe_fl": lfeMains,
|
||||
"lfe_fr": lfeMains,
|
||||
"lfe_fc": lfeMains,
|
||||
"lfe_lfe": lfeDisc,
|
||||
"lfe_rl": lfeMains,
|
||||
"lfe_rr": lfeMains,
|
||||
"lfe_sl": lfeMains,
|
||||
"lfe_sr": lfeMains,
|
||||
"lfe_delay": lfeDelay
|
||||
}
|
||||
}
|
||||
|
||||
function applyFromMap(m) {
|
||||
suppressApply = true
|
||||
fcFold = (Number(m["fc_to_fl"]) + Number(m["fc_to_fr"])) / 2
|
||||
dry = (Number(m["fl_dry"]) + Number(m["fr_dry"])) / 2
|
||||
lfeMains = (Number(m["lfe_fl"]) + Number(m["lfe_fr"]) + Number(m["lfe_fc"])
|
||||
+ Number(m["lfe_rl"]) + Number(m["lfe_rr"]) + Number(m["lfe_sl"])
|
||||
+ Number(m["lfe_sr"])) / 7
|
||||
lfeDisc = Number(m["lfe_lfe"])
|
||||
lfeDelay = Number(m["lfe_delay"])
|
||||
suppressApply = false
|
||||
}
|
||||
|
||||
function scheduleApply() {
|
||||
if (suppressApply)
|
||||
return
|
||||
applyTimer.restart()
|
||||
}
|
||||
|
||||
function linToDb(x) {
|
||||
if (x <= 1e-9)
|
||||
return -120
|
||||
return 20 * Math.log(x) / Math.LN10
|
||||
}
|
||||
|
||||
function dbToLin(db) {
|
||||
if (db <= -120)
|
||||
return 0
|
||||
return Math.pow(10, db / 20)
|
||||
}
|
||||
|
||||
function iconUrl(name) {
|
||||
return "file://" + Backend.iconDir + "/" + name + ".svg"
|
||||
}
|
||||
|
||||
component LucideIcon: Kirigami.Icon {
|
||||
property string name
|
||||
source: root.iconUrl(name)
|
||||
color: Kirigami.Theme.textColor
|
||||
isMask: true
|
||||
Layout.preferredWidth: Kirigami.Units.iconSizes.small
|
||||
Layout.preferredHeight: Kirigami.Units.iconSizes.small
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: applyTimer
|
||||
interval: 40
|
||||
onTriggered: Backend.applyLive(root.valuesMap())
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Backend
|
||||
function onErrorOccurred(title, message) {
|
||||
errorDialog.title = title
|
||||
errorDialog.text = message
|
||||
errorDialog.open()
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
const m = Backend.loadConf()
|
||||
if (Object.keys(m).length)
|
||||
applyFromMap(m)
|
||||
}
|
||||
|
||||
pageStack.initialPage: Kirigami.ScrollablePage {
|
||||
id: page
|
||||
title: "Bass Manage 7.1"
|
||||
|
||||
titleDelegate: Item {
|
||||
// Stretch across the header so actions sit on the right of the title
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: row.implicitHeight
|
||||
width: parent ? parent.width : row.implicitWidth
|
||||
|
||||
RowLayout {
|
||||
id: row
|
||||
anchors.fill: parent
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
LucideIcon {
|
||||
name: "audio-lines"
|
||||
Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium
|
||||
Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium
|
||||
}
|
||||
Kirigami.Heading {
|
||||
text: "Bass Manage 7.1"
|
||||
level: 1
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Controls.ToolButton {
|
||||
onClicked: {
|
||||
if (Backend.saveConf(root.valuesMap()))
|
||||
savedDialog.open()
|
||||
}
|
||||
contentItem: RowLayout {
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
LucideIcon { name: "save" }
|
||||
Controls.Label { text: "Save" }
|
||||
}
|
||||
Accessible.name: "Save"
|
||||
Controls.ToolTip.text: "Save to conf"
|
||||
Controls.ToolTip.visible: hovered
|
||||
Controls.ToolTip.delay: Kirigami.Units.toolTipDelay
|
||||
}
|
||||
|
||||
Controls.ToolButton {
|
||||
onClicked: {
|
||||
const m = Backend.loadConf()
|
||||
if (Object.keys(m).length)
|
||||
root.applyFromMap(m)
|
||||
}
|
||||
contentItem: RowLayout {
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
LucideIcon { name: "refresh-cw" }
|
||||
Controls.Label { text: "Reload" }
|
||||
}
|
||||
Accessible.name: "Reload"
|
||||
Controls.ToolTip.text: "Reload from conf"
|
||||
Controls.ToolTip.visible: hovered
|
||||
Controls.ToolTip.delay: Kirigami.Units.toolTipDelay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
spacing: Kirigami.Units.largeSpacing
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
LucideIcon { name: "audio-lines" }
|
||||
Controls.Label {
|
||||
text: Backend.status
|
||||
opacity: 0.75
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
Kirigami.FormLayout {
|
||||
Layout.fillWidth: true
|
||||
wideMode: true
|
||||
|
||||
Kirigami.Separator {
|
||||
Kirigami.FormData.isSection: true
|
||||
Kirigami.FormData.label: "Center → fronts (add)"
|
||||
}
|
||||
|
||||
SliderRow {
|
||||
Kirigami.FormData.label: "FC into FL/FR"
|
||||
iconName: "align-center-horizontal"
|
||||
from: 0; to: 1.5; stepSize: 0.01
|
||||
kind: "gain"
|
||||
value: root.fcFold
|
||||
onValueEdited: (v) => { root.fcFold = v; root.scheduleApply() }
|
||||
}
|
||||
SliderRow {
|
||||
Kirigami.FormData.label: "FL/FR dry"
|
||||
iconName: "volume-2"
|
||||
from: 0; to: 1.5; stepSize: 0.01
|
||||
kind: "gain"
|
||||
value: root.dry
|
||||
onValueEdited: (v) => { root.dry = v; root.scheduleApply() }
|
||||
}
|
||||
|
||||
Kirigami.Separator {
|
||||
Kirigami.FormData.isSection: true
|
||||
Kirigami.FormData.label: "LFE mix"
|
||||
}
|
||||
|
||||
SliderRow {
|
||||
Kirigami.FormData.label: "Mains → LFE"
|
||||
iconName: "sliders-horizontal"
|
||||
from: 0; to: 1.5; stepSize: 0.01
|
||||
kind: "gain"
|
||||
value: root.lfeMains
|
||||
onValueEdited: (v) => { root.lfeMains = v; root.scheduleApply() }
|
||||
}
|
||||
SliderRow {
|
||||
Kirigami.FormData.label: "Discrete LFE"
|
||||
iconName: "speaker"
|
||||
from: 0; to: 1.5; stepSize: 0.01
|
||||
kind: "gain"
|
||||
value: root.lfeDisc
|
||||
onValueEdited: (v) => { root.lfeDisc = v; root.scheduleApply() }
|
||||
}
|
||||
SliderRow {
|
||||
Kirigami.FormData.label: "LFE delay"
|
||||
iconName: "timer"
|
||||
from: 0; to: 0.05; stepSize: 0.001
|
||||
kind: "delay"
|
||||
value: root.lfeDelay
|
||||
onValueEdited: (v) => { root.lfeDelay = v; root.scheduleApply() }
|
||||
}
|
||||
}
|
||||
|
||||
Controls.Label {
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
opacity: 0.7
|
||||
text: "Type a value and press Enter (gains: 0.707 or -3dB; delay: ms). Reload resets from conf. Save writes conf."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component SliderRow: RowLayout {
|
||||
id: row
|
||||
property real from
|
||||
property real to
|
||||
property real stepSize
|
||||
property real value
|
||||
property string kind: "gain"
|
||||
property string iconName
|
||||
signal valueEdited(real v)
|
||||
|
||||
Layout.fillWidth: true
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
Kirigami.Icon {
|
||||
source: root.iconUrl(row.iconName)
|
||||
color: Kirigami.Theme.textColor
|
||||
isMask: true
|
||||
Layout.preferredWidth: Kirigami.Units.iconSizes.small
|
||||
Layout.preferredHeight: Kirigami.Units.iconSizes.small
|
||||
}
|
||||
|
||||
Controls.Slider {
|
||||
id: slider
|
||||
Layout.fillWidth: true
|
||||
from: row.from
|
||||
to: row.to
|
||||
stepSize: row.stepSize
|
||||
value: row.value
|
||||
onMoved: row.valueEdited(value)
|
||||
}
|
||||
|
||||
Controls.TextField {
|
||||
id: field
|
||||
Layout.preferredWidth: Kirigami.Units.gridUnit * 4
|
||||
horizontalAlignment: Text.AlignRight
|
||||
|
||||
function formatValue(v) {
|
||||
return row.kind === "delay"
|
||||
? Number(v * 1000).toFixed(2).replace(/\.?0+$/, "")
|
||||
: Number(v).toPrecision(4)
|
||||
}
|
||||
|
||||
Component.onCompleted: text = formatValue(row.value)
|
||||
|
||||
Connections {
|
||||
target: row
|
||||
function onValueChanged() {
|
||||
if (!field.activeFocus)
|
||||
field.text = field.formatValue(row.value)
|
||||
}
|
||||
}
|
||||
|
||||
function commit() {
|
||||
let s = text.trim().toLowerCase().replace(",", ".")
|
||||
let parsed = NaN
|
||||
if (row.kind === "delay") {
|
||||
if (s.endsWith("ms"))
|
||||
parsed = parseFloat(s.slice(0, -2)) / 1000
|
||||
else if (s.endsWith("s"))
|
||||
parsed = parseFloat(s.slice(0, -1))
|
||||
else
|
||||
parsed = parseFloat(s) / 1000
|
||||
} else {
|
||||
if (s.endsWith("db"))
|
||||
parsed = root.dbToLin(parseFloat(s.slice(0, -2)))
|
||||
else if (s.startsWith("+") || (s.startsWith("-") && s.indexOf("e") < 0))
|
||||
parsed = root.dbToLin(parseFloat(s))
|
||||
else
|
||||
parsed = parseFloat(s)
|
||||
}
|
||||
if (isNaN(parsed)) {
|
||||
text = formatValue(row.value)
|
||||
return
|
||||
}
|
||||
parsed = Math.min(row.to, Math.max(row.from, parsed))
|
||||
row.valueEdited(parsed)
|
||||
}
|
||||
|
||||
onEditingFinished: commit()
|
||||
Keys.onReturnPressed: commit()
|
||||
Keys.onEnterPressed: commit()
|
||||
}
|
||||
|
||||
Controls.Label {
|
||||
Layout.preferredWidth: Kirigami.Units.gridUnit * 5
|
||||
text: row.kind === "delay"
|
||||
? "ms"
|
||||
: "(" + (root.linToDb(row.value) >= 0 ? "+" : "")
|
||||
+ root.linToDb(row.value).toFixed(1) + " dB)"
|
||||
opacity: 0.7
|
||||
}
|
||||
}
|
||||
|
||||
Controls.Dialog {
|
||||
id: errorDialog
|
||||
property alias text: errorLabel.text
|
||||
modal: true
|
||||
standardButtons: Controls.Dialog.Ok
|
||||
anchors.centerIn: parent
|
||||
Controls.Label {
|
||||
id: errorLabel
|
||||
wrapMode: Text.WordWrap
|
||||
width: errorDialog.availableWidth
|
||||
}
|
||||
}
|
||||
|
||||
Controls.Dialog {
|
||||
id: savedDialog
|
||||
title: "Saved"
|
||||
modal: true
|
||||
standardButtons: Controls.Dialog.Ok
|
||||
anchors.centerIn: parent
|
||||
Controls.Label {
|
||||
wrapMode: Text.WordWrap
|
||||
width: savedDialog.availableWidth
|
||||
text: "Wrote " + Backend.confPath + "\nLive params applied."
|
||||
}
|
||||
}
|
||||
}
|
||||
336
src/backend.cpp
Normal file
@@ -0,0 +1,336 @@
|
||||
#include "backend.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QProcess>
|
||||
#include <QProcessEnvironment>
|
||||
#include <QRegularExpression>
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
namespace {
|
||||
|
||||
QString confFilePath()
|
||||
{
|
||||
return QDir::homePath()
|
||||
+ QStringLiteral("/.config/pipewire/filter-chain.conf.d/50-bass-manage-lfe.conf");
|
||||
}
|
||||
|
||||
double toDouble(const QVariant &v, double fallback)
|
||||
{
|
||||
bool ok = false;
|
||||
const double d = v.toDouble(&ok);
|
||||
return ok ? d : fallback;
|
||||
}
|
||||
|
||||
QString runCapture(const QStringList &args)
|
||||
{
|
||||
QProcess p;
|
||||
p.setProcessChannelMode(QProcess::MergedChannels);
|
||||
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
|
||||
if (!env.contains(QStringLiteral("XDG_RUNTIME_DIR")))
|
||||
env.insert(QStringLiteral("XDG_RUNTIME_DIR"),
|
||||
QStringLiteral("/run/user/%1").arg(getuid()));
|
||||
p.setProcessEnvironment(env);
|
||||
p.start(args.value(0), args.mid(1));
|
||||
if (!p.waitForFinished(8000))
|
||||
return {};
|
||||
return QString::fromUtf8(p.readAllStandardOutput());
|
||||
}
|
||||
|
||||
QString mixerBlock(const QString &text, const QString &name)
|
||||
{
|
||||
const QRegularExpression re(
|
||||
QStringLiteral(
|
||||
R"(name\s*=\s*%1\s*\n\s*type\s*=\s*builtin\s*\n\s*label\s*=\s*mixer\s*\n\s*control\s*=\s*\{(.*?)\n\s*\})")
|
||||
.arg(QRegularExpression::escape(name)),
|
||||
QRegularExpression::DotMatchesEverythingOption);
|
||||
const auto m = re.match(text);
|
||||
return m.hasMatch() ? m.captured(1) : QString();
|
||||
}
|
||||
|
||||
QMap<QString, double> parseGains(const QString &block)
|
||||
{
|
||||
QMap<QString, double> out;
|
||||
const QRegularExpression re(QString::fromUtf8(R"re("([^"]+)"\s*=\s*([0-9.]+))re"));
|
||||
auto it = re.globalMatch(block);
|
||||
while (it.hasNext()) {
|
||||
const auto m = it.next();
|
||||
out.insert(m.captured(1), m.captured(2).toDouble());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool replaceMixer(QString &text, const QString &name, const QMap<QString, double> &gains)
|
||||
{
|
||||
const QRegularExpression re(
|
||||
QStringLiteral(
|
||||
R"((name\s*=\s*%1\s*\n\s*type\s*=\s*builtin\s*\n\s*label\s*=\s*mixer\s*\n\s*control\s*=\s*\{)(.*?)(\n\s*\}))")
|
||||
.arg(QRegularExpression::escape(name)),
|
||||
QRegularExpression::DotMatchesEverythingOption);
|
||||
const auto m = re.match(text);
|
||||
if (!m.hasMatch())
|
||||
return false;
|
||||
QStringList lines;
|
||||
for (auto it = gains.begin(); it != gains.end(); ++it)
|
||||
lines << QStringLiteral(" \"%1\" = %2")
|
||||
.arg(it.key())
|
||||
.arg(it.value(), 0, 'g', 6);
|
||||
const QString block = m.captured(1) + QLatin1Char('\n') + lines.join(QLatin1Char('\n'))
|
||||
+ m.captured(3);
|
||||
text.replace(m.capturedStart(), m.capturedLength(), block);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QString bassManageDataRoot()
|
||||
{
|
||||
#ifdef BASS_MANAGE_SOURCE_DIR
|
||||
{
|
||||
const QString src = QString::fromUtf8(BASS_MANAGE_SOURCE_DIR);
|
||||
if (QDir(src + QStringLiteral("/qml")).exists())
|
||||
return src;
|
||||
}
|
||||
#endif
|
||||
// Installed layout: <prefix>/bin/bass-manage-gui → <prefix>/share/bass-manage
|
||||
{
|
||||
QDir bin(QCoreApplication::applicationDirPath());
|
||||
if (bin.cd(QStringLiteral("../share/bass-manage"))
|
||||
&& QDir(bin.filePath(QStringLiteral("qml"))).exists())
|
||||
return bin.absolutePath();
|
||||
}
|
||||
// Running from build/ inside the source tree
|
||||
{
|
||||
QDir dir(QCoreApplication::applicationDirPath());
|
||||
if (dir.cdUp() && QDir(dir.filePath(QStringLiteral("qml"))).exists())
|
||||
return dir.absolutePath();
|
||||
}
|
||||
return QDir::homePath() + QStringLiteral("/dev/bass-manage");
|
||||
}
|
||||
|
||||
Backend::Backend(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
setStatus(QStringLiteral("Looking for bass_manage.7.1…"));
|
||||
refreshNode();
|
||||
}
|
||||
|
||||
QString Backend::iconDir() const
|
||||
{
|
||||
return bassManageDataRoot() + QStringLiteral("/icons");
|
||||
}
|
||||
|
||||
QString Backend::confPath() const
|
||||
{
|
||||
return confFilePath();
|
||||
}
|
||||
|
||||
void Backend::setStatus(const QString &s)
|
||||
{
|
||||
if (m_status == s)
|
||||
return;
|
||||
m_status = s;
|
||||
emit statusChanged();
|
||||
}
|
||||
|
||||
void Backend::refreshNode()
|
||||
{
|
||||
const bool was = connected();
|
||||
m_nodeId = findNodeId();
|
||||
if (connected() != was)
|
||||
emit connectedChanged();
|
||||
if (m_nodeId < 0)
|
||||
setStatus(QStringLiteral("bass_manage.7.1 not found (is filter-chain running?)"));
|
||||
else if (!m_status.contains(QStringLiteral("Applied"))
|
||||
&& !m_status.contains(QStringLiteral("Reloaded"))
|
||||
&& !m_status.contains(QStringLiteral("Saved")))
|
||||
setStatus(QStringLiteral("Connected · node %1").arg(m_nodeId));
|
||||
}
|
||||
|
||||
int Backend::findNodeId() const
|
||||
{
|
||||
const QString out = runCapture({QStringLiteral("pw-dump")});
|
||||
if (out.isEmpty())
|
||||
return -1;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(out.toUtf8());
|
||||
if (!doc.isArray())
|
||||
return -1;
|
||||
for (const QJsonValue &v : doc.array()) {
|
||||
const QJsonObject o = v.toObject();
|
||||
if (o.value(QStringLiteral("type")).toString()
|
||||
!= QLatin1String("PipeWire:Interface:Node"))
|
||||
continue;
|
||||
const QJsonObject props = o.value(QStringLiteral("info")).toObject()
|
||||
.value(QStringLiteral("props")).toObject();
|
||||
if (props.value(QStringLiteral("node.name")).toString()
|
||||
== QLatin1String("bass_manage.7.1"))
|
||||
return o.value(QStringLiteral("id")).toInt(-1);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
QVariantMap Backend::logicalToPipewire(const QVariantMap &values) const
|
||||
{
|
||||
return {
|
||||
{QStringLiteral("mixFL:Gain 1"), toDouble(values.value(QStringLiteral("fl_dry")), 1.0)},
|
||||
{QStringLiteral("mixFR:Gain 1"), toDouble(values.value(QStringLiteral("fr_dry")), 1.0)},
|
||||
{QStringLiteral("mixFL:Gain 2"), toDouble(values.value(QStringLiteral("fc_to_fl")), 0.707)},
|
||||
{QStringLiteral("mixFR:Gain 2"), toDouble(values.value(QStringLiteral("fc_to_fr")), 0.707)},
|
||||
{QStringLiteral("mixLFE:Gain 1"), toDouble(values.value(QStringLiteral("lfe_fl")), 0.99)},
|
||||
{QStringLiteral("mixLFE:Gain 2"), toDouble(values.value(QStringLiteral("lfe_fr")), 0.99)},
|
||||
{QStringLiteral("mixLFE:Gain 3"), toDouble(values.value(QStringLiteral("lfe_fc")), 0.99)},
|
||||
{QStringLiteral("mixLFE:Gain 4"), toDouble(values.value(QStringLiteral("lfe_lfe")), 0.99)},
|
||||
{QStringLiteral("mixLFE:Gain 5"), toDouble(values.value(QStringLiteral("lfe_rl")), 0.99)},
|
||||
{QStringLiteral("mixLFE:Gain 6"), toDouble(values.value(QStringLiteral("lfe_rr")), 0.99)},
|
||||
{QStringLiteral("mixLFE:Gain 7"), toDouble(values.value(QStringLiteral("lfe_sl")), 0.99)},
|
||||
{QStringLiteral("mixLFE:Gain 8"), toDouble(values.value(QStringLiteral("lfe_sr")), 0.99)},
|
||||
{QStringLiteral("delayLFE:Delay (s)"), toDouble(values.value(QStringLiteral("lfe_delay")), 0.01)},
|
||||
};
|
||||
}
|
||||
|
||||
bool Backend::setParams(int nodeId, const QVariantMap &pipewireParams) const
|
||||
{
|
||||
if (nodeId < 0 || pipewireParams.isEmpty())
|
||||
return false;
|
||||
QStringList parts;
|
||||
for (auto it = pipewireParams.begin(); it != pipewireParams.end(); ++it) {
|
||||
parts << QStringLiteral("\"%1\"").arg(it.key());
|
||||
parts << QString::number(it.value().toDouble(), 'g', 6);
|
||||
}
|
||||
const QString body = QStringLiteral("{ params = [ %1 ] }").arg(parts.join(QLatin1Char(' ')));
|
||||
QProcess p;
|
||||
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
|
||||
if (!env.contains(QStringLiteral("XDG_RUNTIME_DIR")))
|
||||
env.insert(QStringLiteral("XDG_RUNTIME_DIR"),
|
||||
QStringLiteral("/run/user/%1").arg(getuid()));
|
||||
p.setProcessEnvironment(env);
|
||||
p.start(QStringLiteral("pw-cli"),
|
||||
{QStringLiteral("set-param"), QString::number(nodeId), QStringLiteral("Props"), body});
|
||||
return p.waitForFinished(5000) && p.exitCode() == 0;
|
||||
}
|
||||
|
||||
QVariantMap Backend::loadConf()
|
||||
{
|
||||
QFile f(confFilePath());
|
||||
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
emit errorOccurred(QStringLiteral("Reload failed"),
|
||||
QStringLiteral("Missing conf: %1").arg(confFilePath()));
|
||||
return {};
|
||||
}
|
||||
const QString text = QString::fromUtf8(f.readAll());
|
||||
const auto fl = parseGains(mixerBlock(text, QStringLiteral("mixFL")));
|
||||
const auto fr = parseGains(mixerBlock(text, QStringLiteral("mixFR")));
|
||||
const auto lfe = parseGains(mixerBlock(text, QStringLiteral("mixLFE")));
|
||||
const QRegularExpression delayRe(QString::fromUtf8(R"re("Delay \(s\)"\s*=\s*([0-9.]+))re"));
|
||||
const auto dm = delayRe.match(text);
|
||||
if (fl.isEmpty() || fr.isEmpty() || lfe.isEmpty() || !dm.hasMatch()) {
|
||||
emit errorOccurred(QStringLiteral("Reload failed"),
|
||||
QStringLiteral("Could not parse %1").arg(confFilePath()));
|
||||
return {};
|
||||
}
|
||||
|
||||
QVariantMap values{
|
||||
{QStringLiteral("fl_dry"), fl.value(QStringLiteral("Gain 1"), 1.0)},
|
||||
{QStringLiteral("fr_dry"), fr.value(QStringLiteral("Gain 1"), 1.0)},
|
||||
{QStringLiteral("fc_to_fl"), fl.value(QStringLiteral("Gain 2"), 0.707)},
|
||||
{QStringLiteral("fc_to_fr"), fr.value(QStringLiteral("Gain 2"), 0.707)},
|
||||
{QStringLiteral("lfe_fl"), lfe.value(QStringLiteral("Gain 1"), 0.99)},
|
||||
{QStringLiteral("lfe_fr"), lfe.value(QStringLiteral("Gain 2"), 0.99)},
|
||||
{QStringLiteral("lfe_fc"), lfe.value(QStringLiteral("Gain 3"), 0.99)},
|
||||
{QStringLiteral("lfe_lfe"), lfe.value(QStringLiteral("Gain 4"), 0.99)},
|
||||
{QStringLiteral("lfe_rl"), lfe.value(QStringLiteral("Gain 5"), 0.99)},
|
||||
{QStringLiteral("lfe_rr"), lfe.value(QStringLiteral("Gain 6"), 0.99)},
|
||||
{QStringLiteral("lfe_sl"), lfe.value(QStringLiteral("Gain 7"), 0.99)},
|
||||
{QStringLiteral("lfe_sr"), lfe.value(QStringLiteral("Gain 8"), 0.99)},
|
||||
{QStringLiteral("lfe_delay"), dm.captured(1).toDouble()},
|
||||
};
|
||||
|
||||
refreshNode();
|
||||
if (m_nodeId >= 0) {
|
||||
setParams(m_nodeId, logicalToPipewire(values));
|
||||
setStatus(QStringLiteral("Reloaded from conf · node %1").arg(m_nodeId));
|
||||
} else {
|
||||
setStatus(QStringLiteral("Loaded from conf · sink not running"));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
bool Backend::saveConf(const QVariantMap &values)
|
||||
{
|
||||
QFile f(confFilePath());
|
||||
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
emit errorOccurred(QStringLiteral("Save failed"),
|
||||
QStringLiteral("Missing conf: %1").arg(confFilePath()));
|
||||
return false;
|
||||
}
|
||||
QString text = QString::fromUtf8(f.readAll());
|
||||
f.close();
|
||||
|
||||
QMap<QString, double> mixFL{
|
||||
{QStringLiteral("Gain 1"), toDouble(values.value(QStringLiteral("fl_dry")), 1.0)},
|
||||
{QStringLiteral("Gain 2"), toDouble(values.value(QStringLiteral("fc_to_fl")), 0.707)},
|
||||
};
|
||||
QMap<QString, double> mixFR{
|
||||
{QStringLiteral("Gain 1"), toDouble(values.value(QStringLiteral("fr_dry")), 1.0)},
|
||||
{QStringLiteral("Gain 2"), toDouble(values.value(QStringLiteral("fc_to_fr")), 0.707)},
|
||||
};
|
||||
QMap<QString, double> mixLFE{
|
||||
{QStringLiteral("Gain 1"), toDouble(values.value(QStringLiteral("lfe_fl")), 0.99)},
|
||||
{QStringLiteral("Gain 2"), toDouble(values.value(QStringLiteral("lfe_fr")), 0.99)},
|
||||
{QStringLiteral("Gain 3"), toDouble(values.value(QStringLiteral("lfe_fc")), 0.99)},
|
||||
{QStringLiteral("Gain 4"), toDouble(values.value(QStringLiteral("lfe_lfe")), 0.99)},
|
||||
{QStringLiteral("Gain 5"), toDouble(values.value(QStringLiteral("lfe_rl")), 0.99)},
|
||||
{QStringLiteral("Gain 6"), toDouble(values.value(QStringLiteral("lfe_rr")), 0.99)},
|
||||
{QStringLiteral("Gain 7"), toDouble(values.value(QStringLiteral("lfe_sl")), 0.99)},
|
||||
{QStringLiteral("Gain 8"), toDouble(values.value(QStringLiteral("lfe_sr")), 0.99)},
|
||||
};
|
||||
|
||||
if (!replaceMixer(text, QStringLiteral("mixFL"), mixFL)
|
||||
|| !replaceMixer(text, QStringLiteral("mixFR"), mixFR)
|
||||
|| !replaceMixer(text, QStringLiteral("mixLFE"), mixLFE)) {
|
||||
emit errorOccurred(QStringLiteral("Save failed"),
|
||||
QStringLiteral("Could not update mixer blocks"));
|
||||
return false;
|
||||
}
|
||||
|
||||
const double delay = toDouble(values.value(QStringLiteral("lfe_delay")), 0.01);
|
||||
const QRegularExpression delayRe(
|
||||
QString::fromUtf8(R"re((control\s*=\s*\{\s*"Delay \(s\)"\s*=\s*)([0-9.]+))re"));
|
||||
const auto dm = delayRe.match(text);
|
||||
if (!dm.hasMatch()) {
|
||||
emit errorOccurred(QStringLiteral("Save failed"),
|
||||
QStringLiteral("Could not update delay"));
|
||||
return false;
|
||||
}
|
||||
text.replace(dm.capturedStart(), dm.capturedLength(),
|
||||
dm.captured(1) + QString::number(delay, 'g', 6));
|
||||
|
||||
if (!f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) {
|
||||
emit errorOccurred(QStringLiteral("Save failed"), f.errorString());
|
||||
return false;
|
||||
}
|
||||
f.write(text.toUtf8());
|
||||
f.close();
|
||||
|
||||
applyLive(values);
|
||||
setStatus(QStringLiteral("Saved · %1").arg(confFilePath()));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Backend::applyLive(const QVariantMap &values)
|
||||
{
|
||||
refreshNode();
|
||||
if (m_nodeId < 0) {
|
||||
setStatus(QStringLiteral("bass_manage.7.1 not found"));
|
||||
return false;
|
||||
}
|
||||
const bool ok = setParams(m_nodeId, logicalToPipewire(values));
|
||||
setStatus(ok ? QStringLiteral("Applied · node %1").arg(m_nodeId)
|
||||
: QStringLiteral("Apply failed · node %1").arg(m_nodeId));
|
||||
return ok;
|
||||
}
|
||||
44
src/backend.h
Normal file
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QVariantMap>
|
||||
|
||||
/** Project / install data root (contains qml/ and icons/). */
|
||||
QString bassManageDataRoot();
|
||||
|
||||
class Backend : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(QString status READ status NOTIFY statusChanged)
|
||||
Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged)
|
||||
Q_PROPERTY(QString iconDir READ iconDir CONSTANT)
|
||||
Q_PROPERTY(QString confPath READ confPath CONSTANT)
|
||||
|
||||
public:
|
||||
explicit Backend(QObject *parent = nullptr);
|
||||
|
||||
QString status() const { return m_status; }
|
||||
bool connected() const { return m_nodeId >= 0; }
|
||||
QString iconDir() const;
|
||||
QString confPath() const;
|
||||
|
||||
Q_INVOKABLE QVariantMap loadConf();
|
||||
Q_INVOKABLE bool saveConf(const QVariantMap &values);
|
||||
Q_INVOKABLE bool applyLive(const QVariantMap &values);
|
||||
Q_INVOKABLE void refreshNode();
|
||||
|
||||
signals:
|
||||
void statusChanged();
|
||||
void connectedChanged();
|
||||
void errorOccurred(const QString &title, const QString &message);
|
||||
|
||||
private:
|
||||
int findNodeId() const;
|
||||
bool setParams(int nodeId, const QVariantMap &pipewireParams) const;
|
||||
QVariantMap logicalToPipewire(const QVariantMap &values) const;
|
||||
void setStatus(const QString &s);
|
||||
|
||||
int m_nodeId = -1;
|
||||
QString m_status;
|
||||
};
|
||||
41
src/main.cpp
Normal file
@@ -0,0 +1,41 @@
|
||||
#include "backend.h"
|
||||
|
||||
#include <QGuiApplication>
|
||||
#include <QIcon>
|
||||
#include <QQmlApplicationEngine>
|
||||
#include <QQmlContext>
|
||||
#include <QQuickStyle>
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
qputenv("QT_QPA_PLATFORM", "wayland;xcb");
|
||||
|
||||
QGuiApplication app(argc, argv);
|
||||
app.setOrganizationName(QStringLiteral("local"));
|
||||
app.setApplicationName(QStringLiteral("bass-manage"));
|
||||
app.setDesktopFileName(QStringLiteral("bass-manage-gui"));
|
||||
app.setWindowIcon(QIcon::fromTheme(QStringLiteral("bass-manage-gui"),
|
||||
QIcon::fromTheme(QStringLiteral("audio-card"))));
|
||||
|
||||
// Plasma / system style (not Material/Fusion override)
|
||||
if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE"))
|
||||
QQuickStyle::setStyle(QStringLiteral("org.kde.desktop"));
|
||||
|
||||
Backend backend;
|
||||
|
||||
QQmlApplicationEngine engine;
|
||||
engine.rootContext()->setContextProperty(QStringLiteral("Backend"), &backend);
|
||||
|
||||
const QString qmlPath = bassManageDataRoot() + QStringLiteral("/qml/Main.qml");
|
||||
engine.load(QUrl::fromLocalFile(qmlPath));
|
||||
if (engine.rootObjects().isEmpty())
|
||||
return 1;
|
||||
|
||||
QTimer nodeWatch;
|
||||
QObject::connect(&nodeWatch, &QTimer::timeout, &backend, &Backend::refreshNode);
|
||||
nodeWatch.start(2000);
|
||||
|
||||
return app.exec();
|
||||
}
|
||||