commit 0310f8177fd91249e27d564a7f2555b8b90777d7 Author: litruv Date: Thu Aug 27 04:30:14 2026 +1000 Initial bass-manage Qt Quick / Kirigami control panel. PipeWire LFE/center mix UI with live pw-cli controls and conf save/reload. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..888a7ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Build +/build/ +/cmake-build-*/ +*.o +*.a +moc_*.cpp +*_autogen/ + +# Editor / OS +*~ +*.swp +.DS_Store +.idea/ +.vscode/ +*.user +*.autosave diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..4d2cfb9 --- /dev/null +++ b/CMakeLists.txt @@ -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) diff --git a/bass-manage-gui.desktop b/bass-manage-gui.desktop new file mode 100644 index 0000000..8b1824f --- /dev/null +++ b/bass-manage-gui.desktop @@ -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 diff --git a/icons/align-center-horizontal.svg b/icons/align-center-horizontal.svg new file mode 100644 index 0000000..fa366e5 --- /dev/null +++ b/icons/align-center-horizontal.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/icons/audio-lines.svg b/icons/audio-lines.svg new file mode 100644 index 0000000..4a75eb9 --- /dev/null +++ b/icons/audio-lines.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/icons/refresh-cw.svg b/icons/refresh-cw.svg new file mode 100644 index 0000000..9b25b51 --- /dev/null +++ b/icons/refresh-cw.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/icons/save.svg b/icons/save.svg new file mode 100644 index 0000000..f7f5444 --- /dev/null +++ b/icons/save.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/icons/sliders-horizontal.svg b/icons/sliders-horizontal.svg new file mode 100644 index 0000000..c15f4f0 --- /dev/null +++ b/icons/sliders-horizontal.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + diff --git a/icons/speaker.svg b/icons/speaker.svg new file mode 100644 index 0000000..e0d6896 --- /dev/null +++ b/icons/speaker.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/icons/timer.svg b/icons/timer.svg new file mode 100644 index 0000000..1d0b357 --- /dev/null +++ b/icons/timer.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/icons/volume-2.svg b/icons/volume-2.svg new file mode 100644 index 0000000..cfe6b96 --- /dev/null +++ b/icons/volume-2.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/qml/Main.qml b/qml/Main.qml new file mode 100644 index 0000000..1d828e8 --- /dev/null +++ b/qml/Main.qml @@ -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." + } + } +} diff --git a/src/backend.cpp b/src/backend.cpp new file mode 100644 index 0000000..9e740e9 --- /dev/null +++ b/src/backend.cpp @@ -0,0 +1,336 @@ +#include "backend.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +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 parseGains(const QString &block) +{ + QMap 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 &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: /bin/bass-manage-gui → /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 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 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 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; +} diff --git a/src/backend.h b/src/backend.h new file mode 100644 index 0000000..0bfd9f1 --- /dev/null +++ b/src/backend.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include + +/** 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; +}; diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..a0372b7 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,41 @@ +#include "backend.h" + +#include +#include +#include +#include +#include +#include +#include + +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(); +}