Save presets

This commit is contained in:
Joey Yakimowich-Payne 2026-01-27 21:11:20 -07:00
commit b3a1d2b7f3
9 changed files with 542 additions and 4 deletions

View file

@ -90,6 +90,7 @@ add_executable(potato-gui
src/gui/GraphEditorWidget.cpp src/gui/GraphEditorWidget.cpp
src/gui/PipeWireGraphModel.cpp src/gui/PipeWireGraphModel.cpp
src/meters/AudioLevelMeter.cpp src/meters/AudioLevelMeter.cpp
src/presets/PresetManager.cpp
) )
target_link_libraries(potato-gui PRIVATE target_link_libraries(potato-gui PRIVATE

View file

@ -1221,9 +1221,9 @@ private:
### Milestone 4: Virtual Devices & State Management ### Milestone 4: Virtual Devices & State Management
**Estimated Time:** 2 weeks **Estimated Time:** 2 weeks
- [x] Implement virtual sink/source creation via PipeWire adapters - [x] Implement virtual sink/source creation via PipeWire adapters
- [ ] Create `PresetManager` with JSON serialization - [x] Create `PresetManager` with JSON serialization
- [ ] Implement preset load/save functionality - [x] Implement preset load/save functionality
- [ ] Store UI layout alongside audio graph state - [x] Store UI layout alongside audio graph state
- [ ] Implement auto-reconnect for device hotplug - [ ] Implement auto-reconnect for device hotplug
- [ ] **Acceptance Criteria:** Create virtual device, save preset, restore on restart - [ ] **Acceptance Criteria:** Create virtual device, save preset, restore on restart

View file

@ -1,5 +1,6 @@
#include "GraphEditorWidget.h" #include "GraphEditorWidget.h"
#include "meters/AudioLevelMeter.h" #include "meters/AudioLevelMeter.h"
#include "presets/PresetManager.h"
#include <QAction> #include <QAction>
#include <QCoreApplication> #include <QCoreApplication>
@ -175,6 +176,38 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
}); });
m_view->addAction(resetLayoutAction); 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); auto *createVirtualSinkAction = new QAction(QString("Create Virtual Sink"), m_view);
connect(createVirtualSinkAction, &QAction::triggered, [this]() { connect(createVirtualSinkAction, &QAction::triggered, [this]() {
const int index = ++m_virtualSinkCount; const int index = ++m_virtualSinkCount;
@ -233,6 +266,8 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi
m_meterProfileTimer.start(); m_meterProfileTimer.start();
m_meterProfileReady = true; m_meterProfileReady = true;
m_presetManager = new PresetManager(m_controller, m_model, this);
} }
static bool isAudioEndpoint(const Potato::NodeInfo &node) static bool isAudioEndpoint(const Potato::NodeInfo &node)
@ -508,6 +543,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) void GraphEditorWidget::refreshNodeMeter(uint32_t nodeId, const Potato::NodeInfo &node)
{ {
if (m_nodeMeterRows.contains(nodeId)) { if (m_nodeMeterRows.contains(nodeId)) {

View file

@ -18,6 +18,7 @@ class QTimer;
class QScrollArea; class QScrollArea;
class QVBoxLayout; class QVBoxLayout;
class QSplitter; class QSplitter;
class PresetManager;
class GraphEditorWidget : public QWidget class GraphEditorWidget : public QWidget
{ {
@ -46,6 +47,7 @@ private:
void handleLinkRemoved(uint32_t linkId); void handleLinkRemoved(uint32_t linkId);
bool isMeterNode(uint32_t nodeId) const; bool isMeterNode(uint32_t nodeId) const;
int activeLinkCount(uint32_t nodeId) const; int activeLinkCount(uint32_t nodeId) const;
void reloadGraphFromController();
QString connectionKey(const QtNodes::ConnectionId &connectionId) const; QString connectionKey(const QtNodes::ConnectionId &connectionId) const;
bool eventFilter(QObject *object, QEvent *event) override; bool eventFilter(QObject *object, QEvent *event) override;
@ -77,4 +79,5 @@ private:
qint64 m_meterProfileNanos = 0; qint64 m_meterProfileNanos = 0;
qint64 m_meterProfileMax = 0; qint64 m_meterProfileMax = 0;
int m_meterProfileFrames = 0; int m_meterProfileFrames = 0;
PresetManager *m_presetManager = nullptr;
}; };

View file

@ -144,12 +144,19 @@ QWidget *PipeWireGraphModel::nodeWidget(QtNodes::NodeId nodeId) const
slider->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); slider->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
slider->setToolTip(QString("Volume")); 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]() { const auto applyVolume = [this, pipewireId, slider, muteButton]() {
if (!m_controller || pipewireId == 0) { if (!m_controller || pipewireId == 0) {
return; return;
} }
const float volume = static_cast<float>(slider->value()) / 100.0f; const float volume = static_cast<float>(slider->value()) / 100.0f;
m_controller->setNodeVolume(pipewireId, volume, muteButton->isChecked()); m_controller->setNodeVolume(pipewireId, volume, muteButton->isChecked());
m_nodeVolumeState.insert(pipewireId, NodeVolumeState{volume, muteButton->isChecked()});
}; };
QObject::connect(slider, &QSlider::valueChanged, widget, [applyVolume](int) { applyVolume(); }); QObject::connect(slider, &QSlider::valueChanged, widget, [applyVolume](int) { applyVolume(); });
@ -694,6 +701,135 @@ 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);
m_nodeVolumeState.insert(node.id, state);
m_controller->setNodeVolume(node.id, state.volume, state.mute);
auto nodeIt = m_pwToNode.find(node.id);
if (nodeIt == m_pwToNode.end()) {
continue;
}
auto widgetIt = m_nodeWidgets.find(nodeIt->second);
if (widgetIt == m_nodeWidgets.end()) {
continue;
}
QWidget *widget = widgetIt->second;
if (!widget) {
continue;
}
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);
}
}
}
void PipeWireGraphModel::saveLayout() const void PipeWireGraphModel::saveLayout() const
{ {
const QString path = layoutFilePath(); const QString path = layoutFilePath();

View file

@ -18,6 +18,11 @@
class QWidget; class QWidget;
struct NodeVolumeState {
float volume = 1.0f;
bool mute = false;
};
class PipeWireGraphModel : public QtNodes::AbstractGraphModel class PipeWireGraphModel : public QtNodes::AbstractGraphModel
{ {
Q_OBJECT Q_OBJECT
@ -72,6 +77,10 @@ public:
bool viewState(double &scale, QPointF &center) const; bool viewState(double &scale, QPointF &center) const;
void setSplitterSizes(const QList<int> &sizes); void setSplitterSizes(const QList<int> &sizes);
bool splitterSizes(QList<int> &sizes) const; 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);
private: private:
QWidget *nodeWidget(QtNodes::NodeId nodeId) const; QWidget *nodeWidget(QtNodes::NodeId nodeId) const;
@ -100,4 +109,5 @@ private:
bool m_hasSplitterSizes = false; bool m_hasSplitterSizes = false;
mutable std::unordered_map<QtNodes::NodeId, QWidget*> m_nodeWidgets; mutable std::unordered_map<QtNodes::NodeId, QWidget*> m_nodeWidgets;
std::unordered_map<QtNodes::NodeId, QSize> m_nodeSizes; std::unordered_map<QtNodes::NodeId, QSize> m_nodeSizes;
mutable QHash<uint32_t, NodeVolumeState> m_nodeVolumeState;
}; };

View file

@ -545,7 +545,7 @@ bool PipeWireController::createVirtualSink(const QString &name, const QString &d
bool PipeWireController::createVirtualSource(const QString &name, const QString &description, int channels, int rate) bool PipeWireController::createVirtualSource(const QString &name, const QString &description, int channels, int rate)
{ {
return createVirtualDevice(name, description, "support.null-audio-source", "Audio/Source", channels, rate); return createVirtualDevice(name, description, "support.null-audio-sink", "Audio/Source", channels, rate);
} }
float PipeWireController::nodeMeterPeak(uint32_t nodeId) const float PipeWireController::nodeMeterPeak(uint32_t nodeId) const

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