diff --git a/CMakeLists.txt b/CMakeLists.txt index 4884b4c..16eda15 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index a297859..81325c4 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -1221,9 +1221,9 @@ private: ### Milestone 4: Virtual Devices & State Management **Estimated Time:** 2 weeks - [x] 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] 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 diff --git a/src/gui/GraphEditorWidget.cpp b/src/gui/GraphEditorWidget.cpp index 056ff5e..52e3e37 100644 --- a/src/gui/GraphEditorWidget.cpp +++ b/src/gui/GraphEditorWidget.cpp @@ -1,5 +1,6 @@ #include "GraphEditorWidget.h" #include "meters/AudioLevelMeter.h" +#include "presets/PresetManager.h" #include #include @@ -175,6 +176,38 @@ 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; @@ -233,6 +266,8 @@ GraphEditorWidget::GraphEditorWidget(Potato::PipeWireController *controller, QWi m_meterProfileTimer.start(); m_meterProfileReady = true; + + m_presetManager = new PresetManager(m_controller, m_model, this); } 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) { if (m_nodeMeterRows.contains(nodeId)) { diff --git a/src/gui/GraphEditorWidget.h b/src/gui/GraphEditorWidget.h index a9b6600..fe2f8c7 100644 --- a/src/gui/GraphEditorWidget.h +++ b/src/gui/GraphEditorWidget.h @@ -18,6 +18,7 @@ class QTimer; class QScrollArea; class QVBoxLayout; class QSplitter; +class PresetManager; class GraphEditorWidget : public QWidget { @@ -46,6 +47,7 @@ private: 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; @@ -77,4 +79,5 @@ private: qint64 m_meterProfileNanos = 0; qint64 m_meterProfileMax = 0; int m_meterProfileFrames = 0; + PresetManager *m_presetManager = nullptr; }; diff --git a/src/gui/PipeWireGraphModel.cpp b/src/gui/PipeWireGraphModel.cpp index e8fcdc2..17c5d80 100644 --- a/src/gui/PipeWireGraphModel.cpp +++ b/src/gui/PipeWireGraphModel.cpp @@ -144,12 +144,19 @@ QWidget *PipeWireGraphModel::nodeWidget(QtNodes::NodeId nodeId) const 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(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(slider->value()) / 100.0f; m_controller->setNodeVolume(pipewireId, volume, muteButton->isChecked()); + m_nodeVolumeState.insert(pipewireId, NodeVolumeState{volume, muteButton->isChecked()}); }; 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 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 PipeWireGraphModel::volumeStates() const +{ + QHash 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 &states) +{ + if (!m_controller) { + return; + } + + const QVector 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()) { + slider->blockSignals(true); + slider->setValue(static_cast(state.volume * 100.0f)); + slider->blockSignals(false); + } + if (auto *button = widget->findChild()) { + button->blockSignals(true); + button->setChecked(state.mute); + button->blockSignals(false); + } + } +} + void PipeWireGraphModel::saveLayout() const { const QString path = layoutFilePath(); diff --git a/src/gui/PipeWireGraphModel.h b/src/gui/PipeWireGraphModel.h index 64ef775..695e3ad 100644 --- a/src/gui/PipeWireGraphModel.h +++ b/src/gui/PipeWireGraphModel.h @@ -18,6 +18,11 @@ class QWidget; +struct NodeVolumeState { + float volume = 1.0f; + bool mute = false; +}; + class PipeWireGraphModel : public QtNodes::AbstractGraphModel { Q_OBJECT @@ -72,6 +77,10 @@ public: bool viewState(double &scale, QPointF ¢er) const; void setSplitterSizes(const QList &sizes); bool splitterSizes(QList &sizes) const; + QJsonObject layoutJson() const; + void applyLayoutJson(const QJsonObject &root); + QHash volumeStates() const; + void applyVolumeStates(const QHash &states); private: QWidget *nodeWidget(QtNodes::NodeId nodeId) const; @@ -100,4 +109,5 @@ private: bool m_hasSplitterSizes = false; mutable std::unordered_map m_nodeWidgets; std::unordered_map m_nodeSizes; + mutable QHash m_nodeVolumeState; }; diff --git a/src/pipewire/pipewirecontroller.cpp b/src/pipewire/pipewirecontroller.cpp index 14bbe9b..fe46060 100644 --- a/src/pipewire/pipewirecontroller.cpp +++ b/src/pipewire/pipewirecontroller.cpp @@ -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) { - 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 diff --git a/src/presets/PresetManager.cpp b/src/presets/PresetManager.cpp new file mode 100644 index 0000000..fd54274 --- /dev/null +++ b/src/presets/PresetManager.cpp @@ -0,0 +1,293 @@ +#include "presets/PresetManager.h" + +#include "pipewire/pipewirecontroller.h" +#include "gui/PipeWireGraphModel.h" + +#include +#include +#include +#include + +#include + +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 nodes = m_controller->nodes(); + QHash 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 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 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 nodes = m_controller->nodes(); + QHash 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 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(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 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; +} diff --git a/src/presets/PresetManager.h b/src/presets/PresetManager.h new file mode 100644 index 0000000..13cb651 --- /dev/null +++ b/src/presets/PresetManager.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include +#include + +#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; +};