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