Initial bass-manage Qt Quick / Kirigami control panel.

PipeWire LFE/center mix UI with live pw-cli controls and conf save/reload.
This commit is contained in:
2026-08-27 04:30:14 +10:00
commit 0310f8177f
15 changed files with 975 additions and 0 deletions

16
.gitignore vendored Normal file
View 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
View 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
View 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

View 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
View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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();
}