Compare commits

..

10 commits

Author SHA1 Message Date
f681b69467 Undo/redo 2026-01-28 07:13:51 -07:00
adab645c86 Undo volume 2026-01-28 06:51:10 -07:00
debc7f1853 Mixer 2026-01-27 21:49:50 -07:00
f57d39af48 Auto reconnect 2026-01-27 21:25:58 -07:00
b3a1d2b7f3 Save presets 2026-01-27 21:11:20 -07:00
ecfb59501a Virtual 2026-01-27 19:34:30 -07:00
96e1a5cbdb Volume slider works 2026-01-27 19:11:43 -07:00
6d74ef422d Add UI 2026-01-27 18:57:54 -07:00
7f6df30c9e Add progress 2026-01-27 18:34:17 -07:00
8e69c26688 Delete working now 2026-01-27 18:24:36 -07:00
11 changed files with 1587 additions and 62 deletions

View file

@ -33,7 +33,7 @@ pkg_check_modules(SPA REQUIRED libspa-0.2>=0.2)
add_compile_options(
-Wall
-Wextra
-Wpedantic
-Wno-pedantic
-Werror=return-type
)
@ -90,6 +90,7 @@ add_executable(potato-gui
src/gui/GraphEditorWidget.cpp
src/gui/PipeWireGraphModel.cpp
src/meters/AudioLevelMeter.cpp
src/presets/PresetManager.cpp
)
target_link_libraries(potato-gui PRIVATE

View file

@ -1191,52 +1191,52 @@ private:
## 11. Implementation Milestone Plan
### Milestone 1: Core PipeWire Integration
### Milestone 1: Core PipeWire Integration
**Estimated Time:** 2-3 weeks
- [ ] Initialize Qt6 project with CMake
- [ ] Integrate libpipewire with `pw_thread_loop`
- [ ] Implement node/port discovery via registry callbacks
- [ ] Implement link creation/destruction
- [ ] Create lock-free communication primitives (atomics, ring buffers)
- [ ] **Acceptance Criteria:** CLI test app that lists nodes and creates a link programmatically
- [x] Initialize Qt6 project with CMake
- [x] Integrate libpipewire with `pw_thread_loop`
- [x] Implement node/port discovery via registry callbacks
- [x] Implement link creation/destruction
- [x] Create lock-free communication primitives (atomics, ring buffers)
- [x] **Acceptance Criteria:** CLI test app that lists nodes and creates a link programmatically
### Milestone 2: QtNodes Integration
### Milestone 2: QtNodes Integration
**Estimated Time:** 2-3 weeks
- [ ] Integrate QtNodes library (submodule or CMake package)
- [ ] Create `AudioNodeDataModel` for PipeWire nodes
- [ ] Map PipeWire ports to QtNodes handles
- [ ] Implement connection validation
- [ ] Create custom node widgets with embedded controls
- [ ] **Acceptance Criteria:** Visual graph editor displays PipeWire nodes and allows dragging connections
- [x] Integrate QtNodes library (submodule or CMake package)
- [x] Create `AudioNodeDataModel` for PipeWire nodes
- [x] Map PipeWire ports to QtNodes handles
- [x] Implement connection validation
- [x] Create custom node widgets with embedded controls
- [x] **Acceptance Criteria:** Visual graph editor displays PipeWire nodes and allows dragging connections
### Milestone 3: Real-Time Meters & Performance
### Milestone 3: Real-Time Meters & Performance
**Estimated Time:** 1-2 weeks
- [ ] Implement `AudioLevelMeter` with optimized QGraphicsView
- [ ] Create 30Hz update timer with manual viewport control
- [ ] Integrate PipeWire audio callbacks for meter data
- [ ] Implement lock-free meter data transfer (atomics)
- [ ] Profile and optimize rendering performance
- [x] Implement `AudioLevelMeter` with optimized QGraphicsView
- [x] Create 30Hz update timer with manual viewport control
- [x] Integrate PipeWire audio callbacks for meter data
- [x] Implement lock-free meter data transfer (atomics)
- [x] Profile and optimize rendering performance
- [ ] **Acceptance Criteria:** Smooth 30Hz meters with no GUI lag, validated with profiler
### Milestone 4: Virtual Devices & State Management
### Milestone 4: Virtual Devices & State Management
**Estimated Time:** 2 weeks
- [ ] Implement virtual sink/source creation via PipeWire adapters
- [ ] Create `PresetManager` with JSON serialization
- [ ] Implement preset load/save functionality
- [ ] Store UI layout alongside audio graph state
- [x] Implement virtual sink/source creation via PipeWire adapters
- [x] Create `PresetManager` with JSON serialization
- [x] Implement preset load/save functionality
- [x] Store UI layout alongside audio graph state
- [ ] Implement auto-reconnect for device hotplug
- [ ] **Acceptance Criteria:** Create virtual device, save preset, restore on restart
### Milestone 5: Mixer View & Volume Control
### Milestone 5: Mixer View & Volume Control
**Estimated Time:** 1-2 weeks
- [ ] Design traditional mixer UI with faders
- [ ] Implement volume slider with PipeWire parameter sync
- [ ] Add mute buttons and solo functionality
- [ ] Create stereo/multi-channel level meters
- [ ] Implement undo/redo for volume changes
- [x] Design traditional mixer UI with faders
- [x] Implement volume slider with PipeWire parameter sync
- [x] Add mute buttons and solo functionality
- [x] Create stereo/multi-channel level meters
- [x] Implement undo/redo for volume changes
- [ ] **Acceptance Criteria:** Mixer panel controls node volumes, changes persist in presets
### Milestone 6: Undo/Redo & Polish
### Milestone 6: Undo/Redo & Polish
**Estimated Time:** 1-2 weeks
- [ ] Integrate QUndoStack for all graph operations
- [ ] Implement command classes for link, volume, node operations
@ -1245,7 +1245,7 @@ private:
- [ ] Add copy/paste/duplicate functionality
- [ ] **Acceptance Criteria:** Full undo/redo history, keyboard shortcuts work
### Milestone 7: Error Handling & Edge Cases
### Milestone 7: Error Handling & Edge Cases
**Estimated Time:** 1-2 weeks
- [ ] Implement device unplug/replug detection
- [ ] Handle PipeWire service restart with auto-reconnect
@ -1254,7 +1254,7 @@ private:
- [ ] Add error logging with structured JSON output
- [ ] **Acceptance Criteria:** App survives device unplug and PipeWire restart without crashing
### Milestone 8: Final Polish & Release
### Milestone 8: Final Polish & Release
**Estimated Time:** 1-2 weeks
- [ ] Create application icon and desktop file
- [ ] Implement dark/light theme support

27
src/gui/ClickSlider.h Normal file
View file

@ -0,0 +1,27 @@
#pragma once
#include <QMouseEvent>
#include <QSlider>
#include <QStyle>
class ClickSlider : public QSlider
{
public:
using QSlider::QSlider;
protected:
void mousePressEvent(QMouseEvent *event) override
{
if (event->button() == Qt::LeftButton) {
setProperty("pressValue", value());
const int span = (orientation() == Qt::Horizontal) ? width() : height();
const int pos = (orientation() == Qt::Horizontal)
? static_cast<int>(event->position().x())
: static_cast<int>(height() - event->position().y());
const int value = QStyle::sliderValueFromPosition(minimum(), maximum(), pos, span);
setValue(value);
event->accept();
}
QSlider::mousePressEvent(event);
}
};

View file

@ -1,23 +1,70 @@
#include "GraphEditorWidget.h"
#include "meters/AudioLevelMeter.h"
#include "presets/PresetManager.h"
#include <QAction>
#include <QCoreApplication>
#include <QDebug>
#include <QColor>
#include <QFileDialog>
#include <QFontMetrics>
#include <QLabel>
#include <QHBoxLayout>
#include <QSplitter>
#include <QTabWidget>
#include <QSlider>
#include <QToolButton>
#include <QTimer>
#include <QUndoCommand>
#include <QEvent>
#include <QVBoxLayout>
#include <QScrollArea>
#include <QSizePolicy>
#include <QElapsedTimer>
#include <algorithm>
#include <cmath>
#include <QtNodes/GraphicsViewStyle>
#include <QtNodes/NodeStyle>
#include "gui/ClickSlider.h"
class VolumeChangeCommand : public QUndoCommand
{
public:
VolumeChangeCommand(GraphEditorWidget *widget,
uint32_t nodeId,
const NodeVolumeState &previous,
const NodeVolumeState &next)
: m_widget(widget)
, m_nodeId(nodeId)
, m_previous(previous)
, m_next(next)
{
setText(QString("Volume Change"));
}
void undo() override
{
if (m_widget) {
m_widget->applyVolumeState(m_nodeId, m_previous, true);
}
}
void redo() override
{
if (m_widget) {
m_widget->applyVolumeState(m_nodeId, m_next, true);
}
}
private:
GraphEditorWidget *m_widget = nullptr;
uint32_t m_nodeId = 0;
NodeVolumeState m_previous{};
NodeVolumeState m_next{};
};
GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWidget *parent)
: QWidget(parent)
, m_controller(controller)
@ -65,8 +112,16 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
m_splitter->setOrientation(Qt::Horizontal);
m_splitter->addWidget(m_view);
auto *meterPanel = new QWidget(m_splitter);
meterPanel->setStyleSheet("background-color: #1f2126; border-left: 1px solid #2b2f38;");
m_sidebarTabs = new QTabWidget(m_splitter);
m_sidebarTabs->setStyleSheet(
"QTabWidget::pane { border: none; background: #1f2126; border-left: 1px solid #2b2f38; }"
"QTabBar::tab { background: #1f2126; color: #8c94a5; padding: 8px 12px; border: none; font-weight: 700; font-size: 11px; }"
"QTabBar::tab:selected { color: #dbe2ee; border-bottom: 2px solid #5fcf8d; }"
"QTabBar::tab:hover { color: #c7cfdd; }"
);
auto *meterPanel = new QWidget();
meterPanel->setStyleSheet("background-color: #1f2126;");
meterPanel->setMinimumWidth(260);
meterPanel->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding);
@ -102,7 +157,31 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
meterLayout->addStretch();
m_splitter->addWidget(meterPanel);
m_sidebarTabs->addTab(meterPanel, "METERS");
m_mixerTab = new QWidget();
m_mixerTab->setStyleSheet("background-color: #1f2126;");
auto *mixerTabLayout = new QVBoxLayout(m_mixerTab);
mixerTabLayout->setContentsMargins(0, 0, 0, 0);
auto *mixerScroll = new QScrollArea(m_mixerTab);
mixerScroll->setFrameShape(QFrame::NoFrame);
mixerScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
mixerScroll->setWidgetResizable(true);
m_mixerList = new QWidget(mixerScroll);
m_mixerListLayout = new QHBoxLayout(m_mixerList);
m_mixerListLayout->setContentsMargins(20, 20, 20, 20);
m_mixerListLayout->setSpacing(12);
m_mixerListLayout->setAlignment(Qt::AlignLeft);
m_mixerList->setLayout(m_mixerListLayout);
mixerScroll->setWidget(m_mixerList);
mixerTabLayout->addWidget(mixerScroll);
m_sidebarTabs->addTab(m_mixerTab, "MIXER");
m_splitter->addWidget(m_sidebarTabs);
m_splitter->setStretchFactor(0, 1);
m_splitter->setStretchFactor(1, 0);
@ -115,6 +194,16 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
this, &GraphEditorWidget::onConnectionCreated);
connect(m_model, &PipeWireGraphModel::connectionDeleted,
this, &GraphEditorWidget::onConnectionDeleted);
connect(m_model, &PipeWireGraphModel::nodeVolumeChanged,
this, [this](uint32_t nodeId, const NodeVolumeState &previous, const NodeVolumeState &current) {
if (m_ignoreVolumeUndo) {
return;
}
pushVolumeCommand(nodeId, previous, current);
if (m_mixerFaders.contains(nodeId)) {
m_mixerLastState.insert(nodeId, current);
}
});
connect(m_controller, &Potato::PipeWireController::nodeAdded,
this, &GraphEditorWidget::onNodeAdded);
@ -172,6 +261,61 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
});
m_view->addAction(resetLayoutAction);
auto *savePresetAction = new QAction(QString("Save Preset..."), m_view);
connect(savePresetAction, &QAction::triggered, [this]() {
const QString filePath = QFileDialog::getSaveFileName(this,
QString("Save Preset"),
QString(),
QString("Preset Files (*.json)"));
if (filePath.isEmpty()) {
return;
}
if (!m_presetManager->savePreset(filePath)) {
qWarning() << "Failed to save preset" << filePath;
}
});
m_view->addAction(savePresetAction);
auto *loadPresetAction = new QAction(QString("Load Preset..."), m_view);
connect(loadPresetAction, &QAction::triggered, [this]() {
const QString filePath = QFileDialog::getOpenFileName(this,
QString("Load Preset"),
QString(),
QString("Preset Files (*.json)"));
if (filePath.isEmpty()) {
return;
}
if (!m_presetManager->loadPreset(filePath)) {
qWarning() << "Failed to load preset" << filePath;
return;
}
reloadGraphFromController();
});
m_view->addAction(loadPresetAction);
auto *createVirtualSinkAction = new QAction(QString("Create Virtual Sink"), m_view);
connect(createVirtualSinkAction, &QAction::triggered, [this]() {
const int index = ++m_virtualSinkCount;
const QString name = QString("Potato_Virtual_Sink_%1").arg(index);
const QString description = QString("Virtual Sink %1").arg(index);
if (!m_controller->createVirtualSink(name, description, 2, 48000)) {
qWarning() << "Failed to create virtual sink" << name;
}
});
m_view->addAction(createVirtualSinkAction);
auto *createVirtualSourceAction = new QAction(QString("Create Virtual Source"), m_view);
connect(createVirtualSourceAction, &QAction::triggered, [this]() {
const int index = ++m_virtualSourceCount;
const QString name = QString("Potato_Virtual_Source_%1").arg(index);
const QString description = QString("Virtual Source %1").arg(index);
if (!m_controller->createVirtualSource(name, description, 2, 48000)) {
qWarning() << "Failed to create virtual source" << name;
}
});
m_view->addAction(createVirtualSourceAction);
syncGraph();
if (m_model->hasOverlaps()) {
m_model->autoArrange();
@ -205,6 +349,11 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
m_meterTimer->setTimerType(Qt::PreciseTimer);
connect(m_meterTimer, &QTimer::timeout, this, &GraphEditorWidget::updateMeter);
m_meterTimer->start();
m_meterProfileTimer.start();
m_meterProfileReady = true;
m_presetManager = new PresetManager(m_controller, m_model, this);
}
static bool isAudioEndpoint(const Potato::NodeInfo &node)
@ -225,6 +374,7 @@ void GraphEditorWidget::syncGraph()
if (isAudioEndpoint(node)) {
m_model->addPipeWireNode(node);
refreshNodeMeter(node.id, node);
refreshMixerStrip(node.id, node);
}
}
const QVector<Potato::LinkInfo> links = m_controller->links();
@ -255,6 +405,7 @@ void GraphEditorWidget::onNodeAdded(const Potato::NodeInfo &node)
if (isAudioEndpoint(node)) {
m_model->addPipeWireNode(node);
refreshNodeMeter(node.id, node);
refreshMixerStrip(node.id, node);
}
}
@ -270,6 +421,8 @@ void GraphEditorWidget::onNodeChanged(const Potato::NodeInfo &node)
refreshNodeMeter(node.id, node);
updateNodeMeterState(node.id, node);
refreshMixerStrip(node.id, node);
updateMixerState(node.id, node);
}
void GraphEditorWidget::onNodeRemoved(uint32_t nodeId)
@ -288,6 +441,8 @@ void GraphEditorWidget::onNodeRemoved(uint32_t nodeId)
m_nodeMeters.remove(nodeId);
row->deleteLater();
}
removeMixerStrip(nodeId);
}
void GraphEditorWidget::onLinkAdded(const Potato::LinkInfo &link)
@ -383,6 +538,7 @@ void GraphEditorWidget::onConnectionCreated(QtNodes::ConnectionId const connecti
const uint32_t linkId = m_controller->createLink(outInfo->id, outputPortId, inInfo->id, inputPortId);
if (linkId == 0) {
m_model->deleteConnection(connectionId);
return;
}
@ -435,6 +591,12 @@ void GraphEditorWidget::updateMeter()
return;
}
if (!isVisible()) {
return;
}
const qint64 startNanos = m_meterProfileReady ? m_meterProfileTimer.nsecsElapsed() : 0;
const float peak = m_controller->meterPeak();
m_meter->setLevel(peak);
@ -443,6 +605,27 @@ void GraphEditorWidget::updateMeter()
const float nodePeak = m_controller->nodeMeterPeak(nodeId);
it.value()->setLevel(nodePeak);
}
for (auto it = m_mixerMeters.begin(); it != m_mixerMeters.end(); ++it) {
const uint32_t nodeId = static_cast<uint32_t>(it.key());
const float nodePeak = m_controller->nodeMeterPeak(nodeId);
it.value()->setLevel(nodePeak);
}
if (m_meterProfileReady) {
const qint64 duration = m_meterProfileTimer.nsecsElapsed() - startNanos;
m_meterProfileNanos += duration;
m_meterProfileMax = std::max(m_meterProfileMax, duration);
++m_meterProfileFrames;
if (m_meterProfileFrames >= 300) {
const double avgMs = static_cast<double>(m_meterProfileNanos) / (1000000.0 * m_meterProfileFrames);
const double maxMs = static_cast<double>(m_meterProfileMax) / 1000000.0;
qInfo() << "Meter update avg" << avgMs << "ms max" << maxMs << "ms";
m_meterProfileNanos = 0;
m_meterProfileMax = 0;
m_meterProfileFrames = 0;
}
}
}
void GraphEditorWidget::updateLayoutState()
@ -458,6 +641,28 @@ void GraphEditorWidget::updateLayoutState()
}
}
void GraphEditorWidget::reloadGraphFromController()
{
m_ignoreCreate.clear();
m_ignoreDelete.clear();
m_connectionToLinkId.clear();
m_linkIdToConnection.clear();
m_nodeLinkCounts.clear();
m_linksById.clear();
m_model->reset();
syncGraph();
double viewScale = 1.0;
QPointF viewCenter;
if (m_model->viewState(viewScale, viewCenter)) {
m_view->setupScale(viewScale);
m_view->centerOn(viewCenter);
} else {
m_view->zoomFitAll();
}
}
void GraphEditorWidget::refreshNodeMeter(uint32_t nodeId, const Potato::NodeInfo &node)
{
if (m_nodeMeterRows.contains(nodeId)) {
@ -616,3 +821,242 @@ bool GraphEditorWidget::eventFilter(QObject *object, QEvent *event)
return QWidget::eventFilter(object, event);
}
void GraphEditorWidget::refreshMixerStrip(uint32_t nodeId, const Potato::NodeInfo &node)
{
if (m_mixerStrips.contains(nodeId)) {
return;
}
auto *strip = new QWidget(m_mixerList);
strip->setFixedWidth(90);
auto *layout = new QVBoxLayout(strip);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(8);
auto *meter = new AudioLevelMeter(strip);
meter->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
auto *fader = new ClickSlider(Qt::Vertical, strip);
fader->setRange(0, 100);
fader->setValue(100);
fader->setToolTip("Volume");
fader->setStyleSheet(
"QSlider::groove:vertical { width: 4px; background: #2b2f38; border-radius: 2px; }"
"QSlider::sub-page:vertical { background: #2b2f38; border-radius: 2px; }"
"QSlider::add-page:vertical { background: #5fcf8d; border-radius: 2px; }"
"QSlider::handle:vertical { height: 10px; margin: 0 -6px; background: #dbe2ee; border-radius: 5px; }"
);
auto *muteBtn = new QToolButton(strip);
muteBtn->setText("M");
muteBtn->setCheckable(true);
muteBtn->setFixedSize(24, 24);
muteBtn->setToolTip("Mute");
muteBtn->setStyleSheet(
"QToolButton { background: #2b2f38; color: #8c94a5; border: none; border-radius: 4px; font-weight: 700; }"
"QToolButton:checked { background: #c94f4f; color: #ffffff; }"
"QToolButton:hover { background: #3c4350; }"
);
auto *soloBtn = new QToolButton(strip);
soloBtn->setText("S");
soloBtn->setCheckable(true);
soloBtn->setFixedSize(24, 24);
soloBtn->setToolTip("Solo");
soloBtn->setStyleSheet(
"QToolButton { background: #2b2f38; color: #8c94a5; border: none; border-radius: 4px; font-weight: 700; }"
"QToolButton:checked { background: #e0b045; color: #ffffff; }"
"QToolButton:hover { background: #3c4350; }"
);
auto *label = new QLabel(node.description.isEmpty() ? node.name : node.description, strip);
label->setStyleSheet("color: #8c94a5; font-size: 10px; font-weight: 600;");
label->setAlignment(Qt::AlignCenter);
label->setWordWrap(true);
label->setMinimumHeight(48);
layout->addWidget(meter, 1);
layout->addWidget(fader, 0, Qt::AlignHCenter);
auto *btnLayout = new QHBoxLayout();
btnLayout->setContentsMargins(0, 0, 0, 0);
btnLayout->setSpacing(4);
btnLayout->addWidget(muteBtn);
btnLayout->addWidget(soloBtn);
layout->addLayout(btnLayout);
layout->addWidget(label);
m_mixerListLayout->addWidget(strip);
m_mixerStrips.insert(nodeId, strip);
m_mixerMeters.insert(nodeId, meter);
m_mixerFaders.insert(nodeId, fader);
m_mixerMutes.insert(nodeId, muteBtn);
m_mixerSolos.insert(nodeId, soloBtn);
m_mixerUserMute.insert(nodeId, false);
m_mixerLastState.insert(nodeId, NodeVolumeState{1.0f, false});
connect(fader, &QSlider::valueChanged, [this, nodeId](int value) {
if (!m_mixerMutes.contains(nodeId) || !m_mixerFaders.contains(nodeId)) {
return;
}
const float volume = static_cast<float>(value) / 100.0f;
const bool userMute = m_mixerUserMute.value(nodeId, false);
if (m_mixerFaders[nodeId]->isSliderDown()) {
if (!m_mixerStartState.contains(nodeId)) {
const NodeVolumeState previous = m_mixerLastState.value(nodeId, NodeVolumeState{volume, userMute});
m_mixerStartState.insert(nodeId, previous);
}
} else {
const NodeVolumeState previous = m_mixerLastState.value(nodeId, NodeVolumeState{volume, userMute});
const NodeVolumeState next{volume, userMute};
pushVolumeCommand(nodeId, previous, next);
m_mixerLastState.insert(nodeId, next);
}
applySoloState();
});
connect(fader, &QSlider::sliderPressed, [this, nodeId]() {
if (!m_mixerFaders.contains(nodeId)) {
return;
}
const float volume = static_cast<float>(m_mixerFaders[nodeId]->value()) / 100.0f;
const bool userMute = m_mixerUserMute.value(nodeId, false);
const NodeVolumeState previous = m_mixerLastState.value(nodeId, NodeVolumeState{volume, userMute});
m_mixerStartState.insert(nodeId, previous);
});
connect(fader, &QSlider::sliderReleased, [this, nodeId]() {
if (!m_mixerFaders.contains(nodeId)) {
return;
}
const float volume = static_cast<float>(m_mixerFaders[nodeId]->value()) / 100.0f;
const bool userMute = m_mixerUserMute.value(nodeId, false);
const NodeVolumeState previous = m_mixerStartState.value(nodeId, m_mixerLastState.value(nodeId, NodeVolumeState{volume, userMute}));
const NodeVolumeState next{volume, userMute};
m_mixerStartState.remove(nodeId);
pushVolumeCommand(nodeId, previous, next);
m_mixerLastState.insert(nodeId, next);
});
connect(muteBtn, &QToolButton::toggled, [this, nodeId](bool checked) {
if (!m_mixerFaders.contains(nodeId)) {
return;
}
const float volume = static_cast<float>(m_mixerFaders[nodeId]->value()) / 100.0f;
const bool previousMute = m_mixerUserMute.value(nodeId, false);
const NodeVolumeState previous{volume, previousMute};
const NodeVolumeState next{volume, checked};
m_mixerUserMute[nodeId] = checked;
applySoloState();
pushVolumeCommand(nodeId, previous, next);
m_mixerLastState.insert(nodeId, next);
});
connect(soloBtn, &QToolButton::toggled, [this, nodeId](bool checked) {
if (checked) {
m_mixerSoloNodes.insert(nodeId);
} else {
m_mixerSoloNodes.remove(nodeId);
}
applySoloState();
});
updateMixerState(nodeId, node);
}
void GraphEditorWidget::updateMixerState(uint32_t nodeId, const Potato::NodeInfo &node)
{
if (!m_mixerFaders.contains(nodeId) || !m_mixerMutes.contains(nodeId)) {
return;
}
if (node.stableId.isEmpty() || !m_model) {
return;
}
NodeVolumeState state;
if (!m_model->nodeVolumeState(nodeId, state)) {
return;
}
auto *fader = m_mixerFaders.value(nodeId);
auto *muteBtn = m_mixerMutes.value(nodeId);
fader->blockSignals(true);
muteBtn->blockSignals(true);
fader->setValue(static_cast<int>(state.volume * 100.0f));
muteBtn->setChecked(state.mute);
fader->blockSignals(false);
muteBtn->blockSignals(false);
m_mixerUserMute[nodeId] = state.mute;
m_mixerLastState.insert(nodeId, state);
applySoloState();
}
void GraphEditorWidget::removeMixerStrip(uint32_t nodeId)
{
if (m_mixerStrips.contains(nodeId)) {
auto *strip = m_mixerStrips.take(nodeId);
m_mixerMeters.remove(nodeId);
m_mixerFaders.remove(nodeId);
m_mixerMutes.remove(nodeId);
m_mixerSolos.remove(nodeId);
m_mixerUserMute.remove(nodeId);
m_mixerStartState.remove(nodeId);
m_mixerLastState.remove(nodeId);
m_mixerSoloNodes.remove(nodeId);
strip->deleteLater();
}
}
void GraphEditorWidget::applySoloState()
{
for (auto it = m_mixerFaders.begin(); it != m_mixerFaders.end(); ++it) {
const uint32_t nodeId = static_cast<uint32_t>(it.key());
const float volume = static_cast<float>(it.value()->value()) / 100.0f;
const bool userMute = m_mixerUserMute.value(nodeId, false);
const bool soloActive = !m_mixerSoloNodes.isEmpty();
const bool effectiveMute = userMute || (soloActive && !m_mixerSoloNodes.contains(nodeId));
m_controller->setNodeVolume(nodeId, volume, effectiveMute);
if (m_model) {
m_model->setNodeVolumeState(nodeId, NodeVolumeState{volume, userMute}, false);
}
}
}
void GraphEditorWidget::applyVolumeState(uint32_t nodeId, const NodeVolumeState &state, bool updateMixer)
{
Q_UNUSED(updateMixer)
m_ignoreVolumeUndo = true;
if (m_model) {
m_model->setNodeVolumeState(nodeId, state, false);
}
m_mixerUserMute[nodeId] = state.mute;
if (m_mixerFaders.contains(nodeId)) {
m_mixerFaders[nodeId]->blockSignals(true);
m_mixerFaders[nodeId]->setValue(static_cast<int>(state.volume * 100.0f));
m_mixerFaders[nodeId]->blockSignals(false);
}
if (m_mixerMutes.contains(nodeId)) {
m_mixerMutes[nodeId]->blockSignals(true);
m_mixerMutes[nodeId]->setChecked(state.mute);
m_mixerMutes[nodeId]->blockSignals(false);
}
m_mixerLastState.insert(nodeId, state);
m_mixerStartState.remove(nodeId);
applySoloState();
m_ignoreVolumeUndo = false;
}
void GraphEditorWidget::pushVolumeCommand(uint32_t nodeId, const NodeVolumeState &previous, const NodeVolumeState &next)
{
if (!m_scene || m_ignoreVolumeUndo) {
return;
}
const bool changedVolume = qAbs(previous.volume - next.volume) > 0.0001f;
if (!changedVolume && previous.mute == next.mute) {
return;
}
m_scene->undoStack().push(new VolumeChangeCommand(this, nodeId, previous, next));
}

View file

@ -9,6 +9,7 @@
#include <QWidget>
#include <QSet>
#include <QMap>
#include <QElapsedTimer>
#include <cstdint>
class AudioLevelMeter;
@ -16,12 +17,20 @@ class QLabel;
class QTimer;
class QScrollArea;
class QVBoxLayout;
class QHBoxLayout;
class QSlider;
class QToolButton;
class QSplitter;
class QTabWidget;
class PresetManager;
class VolumeChangeCommand;
class GraphEditorWidget : public QWidget
{
Q_OBJECT
friend class VolumeChangeCommand;
public:
explicit GraphEditorWidget(Potato::PipeWireController *controller, QWidget *parent = nullptr);
@ -42,9 +51,16 @@ private:
void updateLayoutState();
void updateNodeMeterLabel(QLabel *label);
void updateNodeMeterState(uint32_t nodeId, const Potato::NodeInfo &node);
void refreshMixerStrip(uint32_t nodeId, const Potato::NodeInfo &node);
void updateMixerState(uint32_t nodeId, const Potato::NodeInfo &node);
void removeMixerStrip(uint32_t nodeId);
void applySoloState();
void applyVolumeState(uint32_t nodeId, const NodeVolumeState &state, bool updateMixer);
void pushVolumeCommand(uint32_t nodeId, const NodeVolumeState &previous, const NodeVolumeState &next);
void handleLinkRemoved(uint32_t linkId);
bool isMeterNode(uint32_t nodeId) const;
int activeLinkCount(uint32_t nodeId) const;
void reloadGraphFromController();
QString connectionKey(const QtNodes::ConnectionId &connectionId) const;
bool eventFilter(QObject *object, QEvent *event) override;
@ -54,11 +70,27 @@ private:
QtNodes::GraphicsView *m_view = nullptr;
QSplitter *m_splitter = nullptr;
QTabWidget *m_sidebarTabs = nullptr;
QWidget *m_mixerTab = nullptr;
QWidget *m_mixerList = nullptr;
QHBoxLayout *m_mixerListLayout = nullptr;
QMap<uint32_t, AudioLevelMeter*> m_mixerMeters;
QMap<uint32_t, QWidget*> m_mixerStrips;
QMap<uint32_t, QSlider*> m_mixerFaders;
QMap<uint32_t, QToolButton*> m_mixerMutes;
QMap<uint32_t, QToolButton*> m_mixerSolos;
QMap<uint32_t, bool> m_mixerUserMute;
QMap<uint32_t, NodeVolumeState> m_mixerStartState;
QMap<uint32_t, NodeVolumeState> m_mixerLastState;
QSet<uint32_t> m_mixerSoloNodes;
QSet<QString> m_ignoreCreate;
QSet<QString> m_ignoreDelete;
QSet<uint32_t> m_ignoreLinkRemoved;
QMap<QString, uint32_t> m_connectionToLinkId;
QMap<uint32_t, QString> m_linkIdToConnection;
int m_virtualSinkCount = 0;
int m_virtualSourceCount = 0;
AudioLevelMeter *m_meter = nullptr;
QTimer *m_meterTimer = nullptr;
QScrollArea *m_meterScroll = nullptr;
@ -69,4 +101,11 @@ private:
QMap<uint32_t, QLabel*> m_nodeMeterLabels;
QMap<uint32_t, int> m_nodeLinkCounts;
QMap<uint32_t, Potato::LinkInfo> m_linksById;
QElapsedTimer m_meterProfileTimer;
bool m_meterProfileReady = false;
qint64 m_meterProfileNanos = 0;
qint64 m_meterProfileMax = 0;
int m_meterProfileFrames = 0;
PresetManager *m_presetManager = nullptr;
bool m_ignoreVolumeUndo = false;
};

View file

@ -16,11 +16,18 @@
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QHBoxLayout>
#include <QSlider>
#include "gui/ClickSlider.h"
#include <QSizePolicy>
#include <QToolButton>
#include <QWidget>
#include <QtNodes/NodeStyle>
#include <QtNodes/StyleCollection>
#include <algorithm>
#include <cmath>
#include <unordered_set>
#include <vector>
@ -108,6 +115,103 @@ PipeWireGraphModel::PipeWireGraphModel(Potato::PipeWireController *controller, Q
}
}
QWidget *PipeWireGraphModel::nodeWidget(QtNodes::NodeId nodeId) const
{
auto it = m_nodeWidgets.find(nodeId);
if (it != m_nodeWidgets.end()) {
return it->second;
}
uint32_t pipewireId = 0;
auto nodeIt = m_nodes.find(nodeId);
if (nodeIt != m_nodes.end()) {
pipewireId = nodeIt->second.id;
}
auto *widget = new QWidget();
auto *layout = new QHBoxLayout(widget);
layout->setContentsMargins(6, 2, 6, 2);
layout->setSpacing(6);
auto *muteButton = new QToolButton(widget);
muteButton->setText("M");
muteButton->setCheckable(true);
muteButton->setFixedSize(20, 20);
muteButton->setToolTip(QString("Mute"));
auto *slider = new ClickSlider(Qt::Horizontal, widget);
slider->setRange(0, 100);
slider->setValue(100);
slider->setFixedHeight(18);
slider->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
slider->setToolTip(QString("Volume"));
if (pipewireId != 0 && m_nodeVolumeState.contains(pipewireId)) {
const NodeVolumeState state = m_nodeVolumeState.value(pipewireId);
slider->setValue(static_cast<int>(state.volume * 100.0f));
muteButton->setChecked(state.mute);
}
const auto applyVolume = [this, pipewireId, slider, muteButton]() {
if (!m_controller || pipewireId == 0) {
return;
}
const float volume = static_cast<float>(slider->value()) / 100.0f;
const NodeVolumeState next{volume, muteButton->isChecked()};
const NodeVolumeState previous = m_nodeVolumeState.value(pipewireId, next);
m_controller->setNodeVolume(pipewireId, volume, next.mute);
auto *self = const_cast<PipeWireGraphModel*>(this);
self->setNodeVolumeState(pipewireId, next, false);
if (!slider->isSliderDown() && !m_inlineStartState.contains(pipewireId)) {
self->emitNodeVolumeChanged(pipewireId, previous, next);
}
};
QObject::connect(slider, &QSlider::valueChanged, widget, [applyVolume](int) { applyVolume(); });
QObject::connect(muteButton, &QToolButton::toggled, widget, [applyVolume](bool) { applyVolume(); });
QObject::connect(slider, &QSlider::sliderPressed, widget, [this, pipewireId, slider, muteButton]() {
if (pipewireId == 0) {
return;
}
bool ok = false;
const int pressValue = slider->property("pressValue").toInt(&ok);
const float volume = ok ? (static_cast<float>(pressValue) / 100.0f)
: static_cast<float>(slider->value()) / 100.0f;
const bool mute = muteButton->isChecked();
m_inlineStartState.insert(pipewireId, NodeVolumeState{volume, mute});
});
QObject::connect(slider, &QSlider::sliderReleased, widget, [this, pipewireId, slider, muteButton]() {
if (pipewireId == 0) {
return;
}
const NodeVolumeState previous = m_inlineStartState.value(pipewireId, m_nodeVolumeState.value(pipewireId));
m_inlineStartState.remove(pipewireId);
const float volume = static_cast<float>(slider->value()) / 100.0f;
const NodeVolumeState next{volume, muteButton->isChecked()};
auto *self = const_cast<PipeWireGraphModel*>(this);
self->emitNodeVolumeChanged(pipewireId, previous, next);
});
layout->addWidget(muteButton);
layout->addWidget(slider);
widget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed);
widget->setFixedHeight(26);
widget->setStyleSheet(
"QToolButton { background: #2b2f38; color: #dbe2ee; border: 1px solid #39404c; border-radius: 4px; font-weight: 700; }"
"QToolButton:checked { background: #3c4350; color: #ffcf7a; }"
"QSlider::groove:horizontal { height: 4px; background: #2b2f38; border-radius: 2px; }"
"QSlider::sub-page:horizontal { background: #5fcf8d; border-radius: 2px; }"
"QSlider::add-page:horizontal { background: #2b2f38; border-radius: 2px; }"
"QSlider::handle:horizontal { width: 10px; margin: -6px 0; background: #dbe2ee; border-radius: 5px; }"
);
widget->adjustSize();
m_nodeWidgets.emplace(nodeId, widget);
return widget;
}
QtNodes::NodeId PipeWireGraphModel::newNodeId()
{
return m_nextNodeId++;
@ -210,6 +314,23 @@ bool PipeWireGraphModel::connectionPossible(QtNodes::ConnectionId const connecti
return false;
}
const auto &outPort = outInfo.outputPorts.at(connectionId.outPortIndex);
const auto &inPort = inInfo.inputPorts.at(connectionId.inPortIndex);
if (outPort.direction != SPA_DIRECTION_OUTPUT || inPort.direction != SPA_DIRECTION_INPUT) {
return false;
}
const auto isAudioClass = [](Potato::MediaClass mediaClass) {
return mediaClass == Potato::MediaClass::AudioSink
|| mediaClass == Potato::MediaClass::AudioSource
|| mediaClass == Potato::MediaClass::AudioDuplex
|| mediaClass == Potato::MediaClass::Stream;
};
if (!isAudioClass(outInfo.mediaClass) || !isAudioClass(inInfo.mediaClass)) {
return false;
}
return true;
}
@ -257,6 +378,10 @@ QVariant PipeWireGraphModel::nodeData(QtNodes::NodeId nodeId, QtNodes::NodeRole
}
case QtNodes::NodeRole::Size:
{
auto sizeIt = m_nodeSizes.find(nodeId);
if (sizeIt != m_nodeSizes.end()) {
return sizeIt->second;
}
const int maxPorts = std::max(info.inputPorts.size(), info.outputPorts.size());
const int baseHeight = 50;
const int perPortHeight = 28;
@ -274,6 +399,8 @@ QVariant PipeWireGraphModel::nodeData(QtNodes::NodeId nodeId, QtNodes::NodeRole
return QString("PipeWire");
case QtNodes::NodeRole::Style:
return nodeStyleVariant(info);
case QtNodes::NodeRole::Widget:
return QVariant::fromValue(nodeWidget(nodeId));
default:
return QVariant();
}
@ -293,6 +420,11 @@ bool PipeWireGraphModel::setNodeData(QtNodes::NodeId nodeId, QtNodes::NodeRole r
return true;
}
if (role == QtNodes::NodeRole::Size) {
m_nodeSizes[nodeId] = value.toSize();
return true;
}
return false;
}
@ -333,9 +465,6 @@ QVariant PipeWireGraphModel::portData(QtNodes::NodeId nodeId,
}
if (role == QtNodes::PortRole::ConnectionPolicyRole) {
if (portType == QtNodes::PortType::In) {
return QVariant::fromValue(QtNodes::ConnectionPolicy::One);
}
return QVariant::fromValue(QtNodes::ConnectionPolicy::Many);
}
@ -379,6 +508,8 @@ bool PipeWireGraphModel::deleteNode(QtNodes::NodeId const nodeId)
m_nodes.erase(nodeId);
m_positions.erase(nodeId);
m_nodeSizes.erase(nodeId);
m_nodeWidgets.erase(nodeId);
Q_EMIT nodeDeleted(nodeId);
return true;
}
@ -476,6 +607,7 @@ bool PipeWireGraphModel::updatePipeWireNode(const Potato::NodeInfo &node)
const QtNodes::NodeId nodeId = it->second;
m_nodes[nodeId] = node;
m_nodeSizes.erase(nodeId);
Q_EMIT nodeUpdated(nodeId);
return true;
}
@ -503,6 +635,8 @@ void PipeWireGraphModel::reset()
m_nodes.clear();
m_pwToNode.clear();
m_positions.clear();
m_nodeSizes.clear();
m_nodeWidgets.clear();
m_nextNodeId = 1;
Q_EMIT modelReset();
}
@ -597,6 +731,168 @@ void PipeWireGraphModel::loadLayout()
}
}
QJsonObject PipeWireGraphModel::layoutJson() const
{
QJsonObject root;
QJsonArray nodes;
for (auto it = m_layoutByStableId.cbegin(); it != m_layoutByStableId.cend(); ++it) {
QJsonObject item;
item["id"] = it.key();
item["x"] = it.value().x();
item["y"] = it.value().y();
nodes.append(item);
}
root["nodes"] = nodes;
QJsonObject view;
view["scale"] = m_viewScale;
view["center_x"] = m_viewCenter.x();
view["center_y"] = m_viewCenter.y();
root["view"] = view;
if (m_hasSplitterSizes && !m_splitterSizes.isEmpty()) {
QJsonArray splitter;
for (const auto size : m_splitterSizes) {
splitter.append(size);
}
root["splitter"] = splitter;
}
return root;
}
void PipeWireGraphModel::applyLayoutJson(const QJsonObject &root)
{
m_layoutByStableId.clear();
m_hasViewState = false;
m_hasSplitterSizes = false;
m_splitterSizes.clear();
const QJsonArray nodes = root.value("nodes").toArray();
applyLayoutData(nodes);
const QJsonObject view = root.value("view").toObject();
if (!view.isEmpty()) {
m_viewScale = view.value("scale").toDouble(1.0);
const double x = view.value("center_x").toDouble(0.0);
const double y = view.value("center_y").toDouble(0.0);
m_viewCenter = QPointF(x, y);
m_hasViewState = true;
}
const QJsonArray splitter = root.value("splitter").toArray();
if (!splitter.isEmpty()) {
QList<int> sizes;
sizes.reserve(splitter.size());
for (const auto &value : splitter) {
sizes.append(value.toInt());
}
if (!sizes.isEmpty()) {
m_splitterSizes = sizes;
m_hasSplitterSizes = true;
}
}
for (const auto &entry : m_nodes) {
const QtNodes::NodeId nodeId = entry.first;
const Potato::NodeInfo &info = entry.second;
if (!info.stableId.isEmpty() && m_layoutByStableId.contains(info.stableId)) {
const QPointF position = m_layoutByStableId.value(info.stableId);
m_positions[nodeId] = position;
Q_EMIT nodePositionUpdated(nodeId);
}
}
}
QHash<QString, NodeVolumeState> PipeWireGraphModel::volumeStates() const
{
QHash<QString, NodeVolumeState> result;
for (const auto &entry : m_nodes) {
const Potato::NodeInfo &info = entry.second;
if (info.stableId.isEmpty()) {
continue;
}
if (!m_nodeVolumeState.contains(info.id)) {
continue;
}
result.insert(info.stableId, m_nodeVolumeState.value(info.id));
}
return result;
}
void PipeWireGraphModel::applyVolumeStates(const QHash<QString, NodeVolumeState> &states)
{
if (!m_controller) {
return;
}
const QVector<Potato::NodeInfo> nodes = m_controller->nodes();
for (const auto &node : nodes) {
if (node.stableId.isEmpty() || !states.contains(node.stableId)) {
continue;
}
const NodeVolumeState state = states.value(node.stableId);
setNodeVolumeState(node.id, state, false);
m_controller->setNodeVolume(node.id, state.volume, state.mute);
}
}
void PipeWireGraphModel::emitNodeVolumeChanged(uint32_t nodeId, const NodeVolumeState &previous, const NodeVolumeState &current)
{
const bool changedVolume = qAbs(previous.volume - current.volume) > 0.0001f;
if (!changedVolume && previous.mute == current.mute) {
return;
}
Q_EMIT nodeVolumeChanged(nodeId, previous, current);
}
void PipeWireGraphModel::setNodeVolumeState(uint32_t nodeId, const NodeVolumeState &state, bool notify)
{
const NodeVolumeState previous = m_nodeVolumeState.value(nodeId, NodeVolumeState{});
m_nodeVolumeState.insert(nodeId, state);
auto nodeIt = m_pwToNode.find(nodeId);
if (nodeIt == m_pwToNode.end()) {
return;
}
auto widgetIt = m_nodeWidgets.find(nodeIt->second);
if (widgetIt == m_nodeWidgets.end()) {
return;
}
QWidget *widget = widgetIt->second;
if (!widget) {
return;
}
if (auto *slider = widget->findChild<QSlider*>()) {
slider->blockSignals(true);
slider->setValue(static_cast<int>(state.volume * 100.0f));
slider->blockSignals(false);
}
if (auto *button = widget->findChild<QToolButton*>()) {
button->blockSignals(true);
button->setChecked(state.mute);
button->blockSignals(false);
}
if (notify) {
const bool changedVolume = std::abs(previous.volume - state.volume) > 0.0001f;
if (changedVolume || previous.mute != state.mute) {
Q_EMIT nodeVolumeChanged(nodeId, previous, state);
}
}
}
bool PipeWireGraphModel::nodeVolumeState(uint32_t nodeId, NodeVolumeState &state) const
{
if (!m_nodeVolumeState.contains(nodeId)) {
return false;
}
state = m_nodeVolumeState.value(nodeId);
return true;
}
void PipeWireGraphModel::saveLayout() const
{
const QString path = layoutFilePath();

View file

@ -16,6 +16,13 @@
#include <unordered_map>
#include <unordered_set>
class QWidget;
struct NodeVolumeState {
float volume = 1.0f;
bool mute = false;
};
class PipeWireGraphModel : public QtNodes::AbstractGraphModel
{
Q_OBJECT
@ -70,8 +77,19 @@ public:
bool viewState(double &scale, QPointF &center) const;
void setSplitterSizes(const QList<int> &sizes);
bool splitterSizes(QList<int> &sizes) const;
QJsonObject layoutJson() const;
void applyLayoutJson(const QJsonObject &root);
QHash<QString, NodeVolumeState> volumeStates() const;
void applyVolumeStates(const QHash<QString, NodeVolumeState> &states);
void setNodeVolumeState(uint32_t nodeId, const NodeVolumeState &state, bool notify = true);
bool nodeVolumeState(uint32_t nodeId, NodeVolumeState &state) const;
signals:
void nodeVolumeChanged(uint32_t nodeId, const NodeVolumeState &previous, const NodeVolumeState &current);
private:
QWidget *nodeWidget(QtNodes::NodeId nodeId) const;
void emitNodeVolumeChanged(uint32_t nodeId, const NodeVolumeState &previous, const NodeVolumeState &current);
QtNodes::ConnectionId connectionFromPipeWire(const Potato::LinkInfo &link, bool *ok) const;
bool findPortIndex(const Potato::NodeInfo &node, uint32_t portId, QtNodes::PortType type, QtNodes::PortIndex &index) const;
QString portLabel(const Potato::PortInfo &port) const;
@ -95,4 +113,8 @@ private:
bool m_hasViewState = false;
QList<int> m_splitterSizes;
bool m_hasSplitterSizes = false;
mutable std::unordered_map<QtNodes::NodeId, QWidget*> m_nodeWidgets;
std::unordered_map<QtNodes::NodeId, QSize> m_nodeSizes;
mutable QHash<uint32_t, NodeVolumeState> m_nodeVolumeState;
mutable QHash<uint32_t, NodeVolumeState> m_inlineStartState;
};

View file

@ -5,6 +5,7 @@
#include <QString>
#include <QElapsedTimer>
#include <QThread>
#include <algorithm>
#include <cstring>
#include <cstdlib>
#include <cmath>
@ -13,14 +14,119 @@
#include <pipewire/keys.h>
#include <pipewire/properties.h>
#include <pipewire/stream.h>
#include <pipewire/node.h>
#include <spa/param/props.h>
#include <spa/param/audio/format-utils.h>
#include <spa/param/audio/raw.h>
#include <spa/utils/dict.h>
#include <spa/utils/defs.h>
#include <spa/utils/type-info.h>
namespace Potato {
static constexpr uint32_t kMeterRingCapacityBytes = 4096;
static void writeRingValue(spa_ringbuffer *ring, std::vector<uint8_t> &buffer, float value)
{
if (!ring || buffer.empty()) {
return;
}
uint32_t index = 0;
const uint32_t avail = spa_ringbuffer_get_write_index(ring, &index);
if (avail < sizeof(float)) {
return;
}
const uint32_t size = static_cast<uint32_t>(buffer.size());
uint32_t offset = index & (size - 1);
if (offset + sizeof(float) <= size) {
std::memcpy(buffer.data() + offset, &value, sizeof(float));
} else {
const uint32_t first = size - offset;
std::memcpy(buffer.data() + offset, &value, first);
std::memcpy(buffer.data(), reinterpret_cast<const uint8_t*>(&value) + first, sizeof(float) - first);
}
spa_ringbuffer_write_update(ring, index + sizeof(float));
}
static bool readRingLatest(spa_ringbuffer *ring, std::vector<uint8_t> &buffer, float &value)
{
if (!ring || buffer.empty()) {
return false;
}
uint32_t index = 0;
const uint32_t avail = spa_ringbuffer_get_read_index(ring, &index);
if (avail < sizeof(float)) {
return false;
}
const uint32_t size = static_cast<uint32_t>(buffer.size());
const uint32_t latestIndex = index + (avail - sizeof(float));
uint32_t offset = latestIndex & (size - 1);
if (offset + sizeof(float) <= size) {
std::memcpy(&value, buffer.data() + offset, sizeof(float));
} else {
const uint32_t first = size - offset;
std::memcpy(&value, buffer.data() + offset, first);
std::memcpy(reinterpret_cast<uint8_t*>(&value) + first, buffer.data(), sizeof(float) - first);
}
spa_ringbuffer_read_update(ring, index + avail);
return true;
}
bool PipeWireController::createVirtualDevice(const QString &name,
const QString &description,
const char *factoryName,
const char *mediaClass,
int channels,
int rate)
{
if (!m_threadLoop || !m_core || name.isEmpty()) {
return false;
}
channels = channels > 0 ? channels : 2;
rate = rate > 0 ? rate : 48000;
const QByteArray nameBytes = name.toUtf8();
const QByteArray descBytes = description.isEmpty() ? nameBytes : description.toUtf8();
const QByteArray channelsBytes = QByteArray::number(channels);
const QByteArray rateBytes = QByteArray::number(rate);
struct spa_dict_item items[] = {
{ PW_KEY_FACTORY_NAME, factoryName },
{ PW_KEY_NODE_NAME, nameBytes.constData() },
{ PW_KEY_NODE_DESCRIPTION, descBytes.constData() },
{ PW_KEY_MEDIA_CLASS, mediaClass },
{ PW_KEY_AUDIO_CHANNELS, channelsBytes.constData() },
{ PW_KEY_AUDIO_RATE, rateBytes.constData() },
{ "object.linger", "true" },
{ PW_KEY_APP_NAME, "Potato-Manager" }
};
struct spa_dict dict = SPA_DICT_INIT(items, SPA_N_ELEMENTS(items));
lock();
auto *proxy = static_cast<struct pw_proxy*>(pw_core_create_object(
m_core,
"adapter",
PW_TYPE_INTERFACE_Node,
PW_VERSION_NODE,
&dict,
0));
unlock();
if (!proxy) {
return false;
}
m_virtualDevices.push_back(proxy);
return true;
}
static QString toQString(const char *value)
{
if (!value) {
@ -63,13 +169,22 @@ void registryEventGlobalRemove(void *data, uint32_t id)
self->m_ports.remove(id);
return;
}
}
bool linkRemoved = false;
{
QMutexLocker lock(&self->m_nodesMutex);
if (self->m_links.contains(id)) {
self->m_links.remove(id);
emit self->linkRemoved(id);
return;
linkRemoved = true;
}
}
if (linkRemoved) {
emit self->linkRemoved(id);
self->handleLinkRemoval(id);
return;
}
}
void coreEventDone(void *data, uint32_t id, int seq)
@ -150,6 +265,9 @@ void meterProcess(void *data)
}
self->m_meterPeak.store(peak, std::memory_order_relaxed);
if (self->m_meterRingReady.load(std::memory_order_relaxed)) {
writeRingValue(&self->m_meterRing, self->m_meterRingData, peak);
}
pw_stream_queue_buffer(self->m_meterStream, buf);
}
@ -217,6 +335,9 @@ PipeWireController::PipeWireController(QObject *parent)
{
m_registryListener = new spa_hook;
m_coreListener = new spa_hook;
m_meterRingData.resize(kMeterRingCapacityBytes);
spa_ringbuffer_init(&m_meterRing);
m_meterRingReady.store(true, std::memory_order_relaxed);
}
PipeWireController::~PipeWireController()
@ -322,6 +443,13 @@ void PipeWireController::shutdown()
m_nodeMeters.clear();
}
for (auto *proxy : m_virtualDevices) {
if (proxy) {
pw_proxy_destroy(proxy);
}
}
m_virtualDevices.clear();
if (m_core) {
pw_core_disconnect(m_core);
m_core = nullptr;
@ -341,6 +469,9 @@ void PipeWireController::shutdown()
pw_deinit();
m_meterRingReady.store(false, std::memory_order_relaxed);
m_meterRingData.clear();
m_initialized.storeRelaxed(false);
m_connected.storeRelaxed(false);
@ -372,7 +503,58 @@ QVector<LinkInfo> PipeWireController::links() const
float PipeWireController::meterPeak() const
{
return m_meterPeak.load(std::memory_order_relaxed);
float peak = m_meterPeak.load(std::memory_order_relaxed);
if (m_meterRingReady.load(std::memory_order_relaxed)) {
float ringPeak = 0.0f;
if (readRingLatest(&m_meterRing, m_meterRingData, ringPeak)) {
peak = ringPeak;
}
}
return peak;
}
bool PipeWireController::setNodeVolume(uint32_t nodeId, float volume, bool mute)
{
if (!m_threadLoop || !m_core || !m_registry) {
return false;
}
if (nodeId == 0) {
return false;
}
volume = std::clamp(volume, 0.0f, 1.0f);
lock();
auto *node = static_cast<struct pw_node*>(
pw_registry_bind(m_registry, nodeId, PW_TYPE_INTERFACE_Node, PW_VERSION_NODE, 0));
if (!node) {
unlock();
return false;
}
uint8_t buffer[128];
spa_pod_builder builder = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer));
auto *param = reinterpret_cast<const struct spa_pod*>(spa_pod_builder_add_object(
&builder,
SPA_TYPE_OBJECT_Props, SPA_PARAM_Props,
SPA_PROP_volume, SPA_POD_Float(volume),
SPA_PROP_mute, SPA_POD_Bool(mute)));
pw_node_set_param(node, SPA_PARAM_Props, 0, param);
pw_proxy_destroy(reinterpret_cast<struct pw_proxy*>(node));
unlock();
return true;
}
bool PipeWireController::createVirtualSink(const QString &name, const QString &description, int channels, int rate)
{
return createVirtualDevice(name, description, "support.null-audio-sink", "Audio/Sink", channels, rate);
}
bool PipeWireController::createVirtualSource(const QString &name, const QString &description, int channels, int rate)
{
return createVirtualDevice(name, description, "support.null-audio-sink", "Audio/Source", channels, rate);
}
float PipeWireController::nodeMeterPeak(uint32_t nodeId) const
@ -596,6 +778,11 @@ bool PipeWireController::destroyLink(uint32_t linkId)
return false;
}
linkInfo = m_links.value(linkId);
m_userRemovedLinks.insert(linkId);
if (m_linkIntentKeys.contains(linkId)) {
const QString key = m_linkIntentKeys.take(linkId);
m_linkIntents.remove(key);
}
}
lock();
@ -609,21 +796,156 @@ bool PipeWireController::destroyLink(uint32_t linkId)
unlock();
QElapsedTimer timer;
timer.start();
while (timer.elapsed() < 2000) {
{
QMutexLocker lock(&m_nodesMutex);
if (!m_links.contains(linkId)) {
qInfo() << "Link destroyed:" << linkId;
qInfo() << "Link destroy requested:" << linkId;
return true;
}
}
QThread::msleep(10);
}
void PipeWireController::rememberLinkIntent(const LinkInfo &link)
{
QString key;
if (!buildLinkIntentKey(link, key)) {
return;
}
qWarning() << "Link destroy requested but ID still present" << linkId;
QMutexLocker lock(&m_nodesMutex);
m_linkIntents.insert(key);
m_linkIntentKeys.insert(link.id, key);
}
void PipeWireController::handleLinkRemoval(uint32_t linkId)
{
QMutexLocker lock(&m_nodesMutex);
if (m_userRemovedLinks.contains(linkId)) {
m_userRemovedLinks.remove(linkId);
}
if (m_linkIntentKeys.contains(linkId)) {
m_linkIntentKeys.remove(linkId);
}
}
void PipeWireController::tryRestoreLinks()
{
QList<QString> intents;
{
QMutexLocker lock(&m_nodesMutex);
intents = m_linkIntents.values();
}
for (const auto &key : intents) {
uint32_t outNodeId = 0;
uint32_t outPortId = 0;
uint32_t inNodeId = 0;
uint32_t inPortId = 0;
if (!resolveLinkIntentKey(key, outNodeId, outPortId, inNodeId, inPortId)) {
continue;
}
createLink(outNodeId, outPortId, inNodeId, inPortId);
}
}
void PipeWireController::updateLinkIntentsForNode(uint32_t nodeId)
{
QVector<LinkInfo> links;
{
QMutexLocker lock(&m_nodesMutex);
for (auto it = m_links.cbegin(); it != m_links.cend(); ++it) {
const LinkInfo &link = it.value();
if (link.outputNodeId == nodeId || link.inputNodeId == nodeId) {
links.append(link);
}
}
}
for (const auto &link : links) {
rememberLinkIntent(link);
}
}
static bool isMeterNodeName(const QString &name)
{
return name.startsWith("Potato-Meter") || name.startsWith("Potato-Node-Meter");
}
bool PipeWireController::buildLinkIntentKey(const LinkInfo &link, QString &key) const
{
QMutexLocker lock(&m_nodesMutex);
if (!m_nodes.contains(link.outputNodeId) || !m_nodes.contains(link.inputNodeId)) {
return false;
}
const NodeInfo &outNode = m_nodes.value(link.outputNodeId);
const NodeInfo &inNode = m_nodes.value(link.inputNodeId);
if (isMeterNodeName(outNode.name) || isMeterNodeName(inNode.name)) {
return false;
}
QString outPortName;
QString inPortName;
for (const auto &port : outNode.outputPorts) {
if (port.id == link.outputPortId) {
outPortName = port.name;
break;
}
}
for (const auto &port : inNode.inputPorts) {
if (port.id == link.inputPortId) {
inPortName = port.name;
break;
}
}
if (outPortName.isEmpty() || inPortName.isEmpty()) {
return false;
}
if (outNode.stableId.isEmpty() || inNode.stableId.isEmpty()) {
return false;
}
key = QString("%1||%2>>%3||%4").arg(outNode.stableId, outPortName, inNode.stableId, inPortName);
return true;
}
bool PipeWireController::resolveLinkIntentKey(const QString &key, uint32_t &outNodeId, uint32_t &outPortId,
uint32_t &inNodeId, uint32_t &inPortId) const
{
const QStringList halves = key.split(">>");
if (halves.size() != 2) {
return false;
}
const QStringList outParts = halves.at(0).split("||");
const QStringList inParts = halves.at(1).split("||");
if (outParts.size() != 2 || inParts.size() != 2) {
return false;
}
const QString outStableId = outParts.at(0);
const QString outPortName = outParts.at(1);
const QString inStableId = inParts.at(0);
const QString inPortName = inParts.at(1);
QMutexLocker lock(&m_nodesMutex);
for (const auto &nodeEntry : m_nodes) {
const NodeInfo &node = nodeEntry;
if (node.stableId == outStableId) {
for (const auto &port : node.outputPorts) {
if (port.name == outPortName) {
outNodeId = node.id;
outPortId = port.id;
break;
}
}
}
if (node.stableId == inStableId) {
for (const auto &port : node.inputPorts) {
if (port.name == inPortName) {
inNodeId = node.id;
inPortId = port.id;
break;
}
}
}
}
return outNodeId != 0 && outPortId != 0 && inNodeId != 0 && inPortId != 0;
}
QString PipeWireController::dumpGraph() const
@ -710,6 +1032,9 @@ void PipeWireController::handleNodeInfo(uint32_t id, const struct spa_dict *prop
qDebug() << "Node changed:" << node.id << node.name;
}
}
updateLinkIntentsForNode(id);
tryRestoreLinks();
}
void PipeWireController::handlePortInfo(uint32_t id, const struct spa_dict *props)
@ -737,6 +1062,8 @@ void PipeWireController::handlePortInfo(uint32_t id, const struct spa_dict *prop
uint32_t nodeId = nodeIdStr ? static_cast<uint32_t>(atoi(nodeIdStr)) : 0;
PortInfo port(id, nodeId, portName, direction);
bool emitChanged = false;
NodeInfo nodeSnapshot;
{
QMutexLocker lock(&m_nodesMutex);
m_ports.insert(id, port);
@ -751,9 +1078,18 @@ void PipeWireController::handlePortInfo(uint32_t id, const struct spa_dict *prop
}
}
ports.append(port);
nodeSnapshot = node;
emitChanged = true;
}
}
if (emitChanged) {
emit nodeChanged(nodeSnapshot);
}
updateLinkIntentsForNode(nodeId);
tryRestoreLinks();
qDebug() << "Port added:" << id << portName << "direction:" << direction;
}
@ -782,6 +1118,8 @@ void PipeWireController::handleLinkInfo(uint32_t id, const struct spa_dict *prop
emit linkAdded(link);
rememberLinkIntent(link);
qDebug() << "Link added:" << id << "from" << outputNode << ":" << outputPort
<< "to" << inputNode << ":" << inputPort;
}

View file

@ -5,13 +5,19 @@
#include <QMap>
#include <QMutex>
#include <QAtomicInteger>
#include <QHash>
#include <QSet>
#include <atomic>
#include <vector>
#include <spa/utils/ringbuffer.h>
struct pw_thread_loop;
struct pw_context;
struct pw_core;
struct pw_registry;
struct pw_stream;
struct pw_proxy;
struct spa_hook;
struct spa_dict;
namespace Potato {
@ -38,6 +44,9 @@ public:
float nodeMeterPeak(uint32_t nodeId) const;
void ensureNodeMeter(uint32_t nodeId, const QString &targetName, bool captureSink);
void removeNodeMeter(uint32_t nodeId);
bool setNodeVolume(uint32_t nodeId, float volume, bool mute);
bool createVirtualSink(const QString &name, const QString &description, int channels, int rate);
bool createVirtualSource(const QString &name, const QString &description, int channels, int rate);
uint32_t createLink(uint32_t outputNodeId, uint32_t outputPortId,
uint32_t inputNodeId, uint32_t inputPortId);
@ -72,6 +81,16 @@ private:
void handleLinkInfo(uint32_t id, const struct ::spa_dict *props);
bool setupMeterStream();
void teardownMeterStream();
bool createVirtualDevice(const QString &name, const QString &description,
const char *factoryName, const char *mediaClass,
int channels, int rate);
void rememberLinkIntent(const LinkInfo &link);
void handleLinkRemoval(uint32_t linkId);
void tryRestoreLinks();
void updateLinkIntentsForNode(uint32_t nodeId);
bool buildLinkIntentKey(const LinkInfo &link, QString &key) const;
bool resolveLinkIntentKey(const QString &key, uint32_t &outNodeId, uint32_t &outPortId,
uint32_t &inNodeId, uint32_t &inPortId) const;
void lock();
void unlock();
@ -89,10 +108,18 @@ private:
QMap<uint32_t, NodeInfo> m_nodes;
QMap<uint32_t, PortInfo> m_ports;
QMap<uint32_t, LinkInfo> m_links;
QSet<QString> m_linkIntents;
QHash<uint32_t, QString> m_linkIntentKeys;
QSet<uint32_t> m_userRemovedLinks;
QAtomicInteger<bool> m_connected{false};
QAtomicInteger<bool> m_initialized{false};
std::atomic<float> m_meterPeak{0.0f};
mutable spa_ringbuffer m_meterRing{};
mutable std::vector<uint8_t> m_meterRingData;
std::atomic<bool> m_meterRingReady{false};
std::vector<struct pw_proxy*> m_virtualDevices;
mutable QMutex m_meterMutex;
QMap<uint32_t, NodeMeter*> m_nodeMeters;

View file

@ -0,0 +1,293 @@
#include "presets/PresetManager.h"
#include "pipewire/pipewirecontroller.h"
#include "gui/PipeWireGraphModel.h"
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QVector>
#include <algorithm>
PresetManager::PresetManager(Potato::PipeWireController *controller,
PipeWireGraphModel *model,
QObject *parent)
: QObject(parent)
, m_controller(controller)
, m_model(model)
{
}
bool PresetManager::savePreset(const QString &path) const
{
if (!m_controller || !m_model || path.isEmpty()) {
return false;
}
const QJsonObject root = buildPreset();
const QJsonDocument doc(root);
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return false;
}
file.write(doc.toJson(QJsonDocument::Indented));
return true;
}
bool PresetManager::loadPreset(const QString &path)
{
if (!m_controller || !m_model || path.isEmpty()) {
return false;
}
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
return false;
}
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
if (!doc.isObject()) {
return false;
}
return applyPreset(doc.object());
}
QJsonObject PresetManager::buildPreset() const
{
QJsonObject root;
root["version"] = QString("1.0");
const QVector<Potato::NodeInfo> nodes = m_controller->nodes();
QHash<uint32_t, Potato::NodeInfo> nodesById;
for (const auto &node : nodes) {
nodesById.insert(node.id, node);
}
QJsonArray virtualDevices;
for (const auto &node : nodes) {
if (node.type != Potato::NodeType::Virtual) {
continue;
}
QJsonObject device;
device["name"] = node.name;
device["description"] = node.description;
device["stable_id"] = node.stableId;
const int channels = std::max(node.inputPorts.size(), node.outputPorts.size());
device["channels"] = channels > 0 ? channels : 2;
device["rate"] = 48000;
device["media_class"] = mediaClassToString(node.mediaClass);
virtualDevices.append(device);
}
root["virtual_devices"] = virtualDevices;
QJsonArray routing;
const QVector<Potato::LinkInfo> links = m_controller->links();
for (const auto &link : links) {
if (!nodesById.contains(link.outputNodeId) || !nodesById.contains(link.inputNodeId)) {
continue;
}
const Potato::NodeInfo &outNode = nodesById.value(link.outputNodeId);
const Potato::NodeInfo &inNode = nodesById.value(link.inputNodeId);
QString outPortName;
QString inPortName;
if (!findPortName(outNode, link.outputPortId, outPortName)) {
continue;
}
if (!findPortName(inNode, link.inputPortId, inPortName)) {
continue;
}
QJsonObject route;
route["source"] = QString("%1:%2").arg(outNode.stableId, outPortName);
route["target"] = QString("%1:%2").arg(inNode.stableId, inPortName);
route["volume"] = 1.0;
route["muted"] = false;
routing.append(route);
}
root["routing"] = routing;
QJsonObject volumes;
QJsonObject mutes;
const QHash<QString, NodeVolumeState> volumeStates = m_model->volumeStates();
for (auto it = volumeStates.cbegin(); it != volumeStates.cend(); ++it) {
volumes[it.key()] = it.value().volume;
mutes[it.key()] = it.value().mute;
}
root["persistent_volumes"] = volumes;
root["persistent_mutes"] = mutes;
root["ui_layout"] = m_model->layoutJson();
return root;
}
bool PresetManager::applyPreset(const QJsonObject &root)
{
const QVector<Potato::NodeInfo> nodes = m_controller->nodes();
QHash<QString, Potato::NodeInfo> nodesByStableId;
for (const auto &node : nodes) {
if (!node.stableId.isEmpty()) {
nodesByStableId.insert(node.stableId, node);
}
}
const QJsonArray virtualDevices = root.value("virtual_devices").toArray();
for (const auto &entry : virtualDevices) {
const QJsonObject device = entry.toObject();
const QString stableId = device.value("stable_id").toString();
const QString name = device.value("name").toString();
const QString description = device.value("description").toString();
const QString mediaClassValue = device.value("media_class").toString();
const int channels = device.value("channels").toInt(2);
const int rate = device.value("rate").toInt(48000);
if (!stableId.isEmpty() && nodesByStableId.contains(stableId)) {
continue;
}
const Potato::MediaClass mediaClass = mediaClassFromString(mediaClassValue);
if (mediaClass == Potato::MediaClass::AudioSource) {
m_controller->createVirtualSource(name, description, channels, rate);
} else {
m_controller->createVirtualSink(name, description, channels, rate);
}
}
const QJsonArray routing = root.value("routing").toArray();
for (const auto &entry : routing) {
const QJsonObject route = entry.toObject();
const QString source = route.value("source").toString();
const QString target = route.value("target").toString();
uint32_t outNodeId = 0;
uint32_t outPortId = 0;
uint32_t inNodeId = 0;
uint32_t inPortId = 0;
if (!parsePortKey(source, outNodeId, outPortId)) {
continue;
}
if (!parsePortKey(target, inNodeId, inPortId)) {
continue;
}
m_controller->createLink(outNodeId, outPortId, inNodeId, inPortId);
}
QHash<QString, NodeVolumeState> volumeStates;
const QJsonObject volumes = root.value("persistent_volumes").toObject();
const QJsonObject mutes = root.value("persistent_mutes").toObject();
for (auto it = volumes.begin(); it != volumes.end(); ++it) {
NodeVolumeState state;
state.volume = static_cast<float>(it.value().toDouble(1.0));
state.mute = mutes.value(it.key()).toBool(false);
volumeStates.insert(it.key(), state);
}
if (!volumeStates.isEmpty()) {
m_model->applyVolumeStates(volumeStates);
}
const QJsonObject layout = root.value("ui_layout").toObject();
if (!layout.isEmpty()) {
m_model->applyLayoutJson(layout);
}
return true;
}
bool PresetManager::findPortName(const Potato::NodeInfo &node, uint32_t portId, QString &portName) const
{
for (const auto &port : node.inputPorts) {
if (port.id == portId) {
portName = port.name;
return true;
}
}
for (const auto &port : node.outputPorts) {
if (port.id == portId) {
portName = port.name;
return true;
}
}
return false;
}
bool PresetManager::parsePortKey(const QString &key, uint32_t &nodeId, uint32_t &portId) const
{
const int split = key.lastIndexOf(':');
if (split <= 0) {
return false;
}
const QString stableId = key.left(split);
const QString portName = key.mid(split + 1);
if (stableId.isEmpty() || portName.isEmpty()) {
return false;
}
const QVector<Potato::NodeInfo> nodes = m_controller->nodes();
for (const auto &node : nodes) {
if (node.stableId != stableId) {
continue;
}
for (const auto &port : node.inputPorts) {
if (port.name == portName) {
nodeId = node.id;
portId = port.id;
return true;
}
}
for (const auto &port : node.outputPorts) {
if (port.name == portName) {
nodeId = node.id;
portId = port.id;
return true;
}
}
}
return false;
}
QString PresetManager::portKey(const Potato::NodeInfo &node, const Potato::PortInfo &port) const
{
return QString("%1:%2").arg(node.stableId, port.name);
}
QString PresetManager::mediaClassToString(Potato::MediaClass mediaClass) const
{
switch (mediaClass) {
case Potato::MediaClass::AudioSink:
return QString("Audio/Sink");
case Potato::MediaClass::AudioSource:
return QString("Audio/Source");
case Potato::MediaClass::AudioDuplex:
return QString("Audio/Duplex");
case Potato::MediaClass::Stream:
return QString("Stream");
default:
return QString();
}
}
Potato::MediaClass PresetManager::mediaClassFromString(const QString &value) const
{
if (value.contains("Audio/Source")) {
return Potato::MediaClass::AudioSource;
}
if (value.contains("Audio/Duplex")) {
return Potato::MediaClass::AudioDuplex;
}
if (value.contains("Audio/Sink")) {
return Potato::MediaClass::AudioSink;
}
if (value.contains("Stream")) {
return Potato::MediaClass::Stream;
}
return Potato::MediaClass::Unknown;
}

View file

@ -0,0 +1,38 @@
#pragma once
#include <QObject>
#include <QHash>
#include <QJsonObject>
#include <QString>
#include <cstdint>
#include "pipewire/nodeinfo.h"
class PipeWireGraphModel;
namespace Potato {
class PipeWireController;
}
class PresetManager : public QObject
{
public:
explicit PresetManager(Potato::PipeWireController *controller,
PipeWireGraphModel *model,
QObject *parent = nullptr);
bool savePreset(const QString &path) const;
bool loadPreset(const QString &path);
private:
QJsonObject buildPreset() const;
bool applyPreset(const QJsonObject &root);
bool findPortName(const Potato::NodeInfo &node, uint32_t portId, QString &portName) const;
bool parsePortKey(const QString &key, uint32_t &nodeId, uint32_t &portId) const;
QString portKey(const Potato::NodeInfo &node, const Potato::PortInfo &port) const;
QString mediaClassToString(Potato::MediaClass mediaClass) const;
Potato::MediaClass mediaClassFromString(const QString &value) const;
Potato::PipeWireController *m_controller = nullptr;
PipeWireGraphModel *m_model = nullptr;
};