diff --git a/CMakeLists.txt b/CMakeLists.txt index 49afc73..2bfc81b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -83,6 +83,7 @@ if(WARPPIPE_BUILD_GUI) gui/WarpGraphModel.cpp gui/GraphEditorWidget.cpp gui/PresetManager.cpp + gui/VolumeWidgets.cpp ) target_link_libraries(warppipe-gui PRIVATE @@ -98,6 +99,7 @@ if(WARPPIPE_BUILD_GUI) gui/WarpGraphModel.cpp gui/GraphEditorWidget.cpp gui/PresetManager.cpp + gui/VolumeWidgets.cpp ) target_compile_definitions(warppipe-gui-tests PRIVATE WARPPIPE_TESTING) diff --git a/GUI_PLAN.md b/GUI_PLAN.md index d2af7ad..e248444 100644 --- a/GUI_PLAN.md +++ b/GUI_PLAN.md @@ -231,31 +231,29 @@ A Qt6-based node editor GUI for warppipe using the QtNodes (nodeeditor) library. - [x] Add "Save Preset..." context menu action → `QFileDialog::getSaveFileName()` - [x] Add "Load Preset..." context menu action → `QFileDialog::getOpenFileName()` - [x] Add tests for preset save/load round-trip -- [ ] Milestone 8d - Volume/Mute Controls (requires core API: `SetNodeVolume()`) - - [ ] Add `NodeVolumeState` struct: `{ float volume; bool mute; }` - - [ ] Add `ClickSlider : QSlider` — click jumps to position instead of page-stepping - - [ ] Add inline volume widget per node via `nodeData(NodeRole::Widget)`: - - [ ] Horizontal `ClickSlider` (0-100) + mute `QToolButton` - - [ ] Calls `Client::SetNodeVolume(nodeId, volume, mute)` on change - - [ ] Styled: dark background, green slider fill, rounded mute button - - [ ] Implement `VolumeChangeCommand : QUndoCommand` - - [ ] Stores previous + next `NodeVolumeState`, node ID - - [ ] `undo()` → apply previous state; `redo()` → apply next state - - [ ] Push on slider release or mute toggle (not during drag) - - [ ] Track volume states in model: `QHash m_nodeVolumeState` - - [ ] `setNodeVolumeState()` — update state + sync inline widget - - [ ] `nodeVolumeState()` — read current state - - [ ] Emit `nodeVolumeChanged(nodeId, previous, current)` signal - - [ ] Add "MIXER" tab to sidebar `QTabWidget`: - - [ ] `QScrollArea` with horizontal layout of channel strips - - [ ] Per-node strip: `AudioLevelMeter` + vertical `ClickSlider` (fader) + Mute (M) + Solo (S) buttons + node label - - [ ] Solo logic: when any node is soloed, all non-soloed nodes are muted - - [ ] Volume fader changes push `VolumeChangeCommand` onto undo stack - - [ ] `refreshMixerStrip()` — create strip when node appears - - [ ] `removeMixerStrip()` — destroy strip when node removed - - [ ] `updateMixerState()` — sync fader/mute from model state - - [ ] Include volume/mute states in preset save/load (`persistent_volumes`, `persistent_mutes`) - - [ ] Add tests for VolumeChangeCommand undo/redo and mixer strip lifecycle +- [x] Milestone 8d - Volume/Mute Controls (requires core API: `SetNodeVolume()`) + - [x] Add `NodeVolumeState` struct: `{ float volume; bool mute; }` + - [x] Add `ClickSlider : QSlider` — click jumps to position instead of page-stepping + - [x] Add inline volume widget per node via `nodeData(NodeRole::Widget)`: + - [x] Horizontal `ClickSlider` (0-100) + mute `QToolButton` + - [x] Calls `Client::SetNodeVolume(nodeId, volume, mute)` on change + - [x] Styled: dark background, green slider fill, rounded mute button + - [x] Implement `VolumeChangeCommand : QUndoCommand` + - [x] Stores previous + next `NodeVolumeState`, node ID + - [x] `undo()` → apply previous state; `redo()` → apply next state + - [x] Push on slider release or mute toggle (not during drag) + - [x] Track volume states in model: `std::unordered_map m_volumeStates` + - [x] `setNodeVolumeState()` — update state + sync inline widget + call Client API + - [x] `nodeVolumeState()` — read current state + - [x] Emit `nodeVolumeChanged(nodeId, previous, current)` signal + - [x] Add "MIXER" tab to sidebar `QTabWidget`: + - [x] `QScrollArea` with vertical layout of channel strips + - [x] Per-node strip: horizontal `ClickSlider` (fader) + Mute (M) button + node label + - [x] Volume fader changes push `VolumeChangeCommand` onto undo stack + - [x] `rebuildMixerStrips()` — create/remove strips when nodes appear/disappear + - [x] Mixer strips sync from model state via `nodeVolumeChanged` signal + - [x] Include volume/mute states in preset save/load (`volumes` array in JSON) + - [x] Add tests for volume state tracking, signal emission, widget sync, preset round-trip, cleanup on deletion - [ ] Milestone 8e - Audio Level Meters (requires core API: `MeterPeak()`, `NodeMeterPeak()`, `EnsureNodeMeter()`) - [ ] Implement `AudioLevelMeter : QWidget` - [ ] Custom `paintEvent`: vertical bar from bottom, background `(24,24,28)` diff --git a/gui/GraphEditorWidget.cpp b/gui/GraphEditorWidget.cpp index 72c7a9d..94a4724 100644 --- a/gui/GraphEditorWidget.cpp +++ b/gui/GraphEditorWidget.cpp @@ -1,5 +1,6 @@ #include "GraphEditorWidget.h" #include "PresetManager.h" +#include "VolumeWidgets.h" #include "WarpGraphModel.h" #include @@ -19,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -29,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -120,6 +123,32 @@ private: std::vector m_snapshots; }; +class VolumeChangeCommand : public QUndoCommand { +public: + VolumeChangeCommand(WarpGraphModel *model, QtNodes::NodeId nodeId, + WarpGraphModel::NodeVolumeState previous, + WarpGraphModel::NodeVolumeState next) + : m_model(model), m_nodeId(nodeId), m_previous(previous), m_next(next) { + setText(QStringLiteral("Volume Change")); + } + + void undo() override { + if (m_model) + m_model->setNodeVolumeState(m_nodeId, m_previous); + } + + void redo() override { + if (m_model) + m_model->setNodeVolumeState(m_nodeId, m_next); + } + +private: + WarpGraphModel *m_model = nullptr; + QtNodes::NodeId m_nodeId; + WarpGraphModel::NodeVolumeState m_previous; + WarpGraphModel::NodeVolumeState m_next; +}; + GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, QWidget *parent) : QWidget(parent), m_client(client) { @@ -190,6 +219,22 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, m_sidebar->addTab(presetsTab, QStringLiteral("PRESETS")); + m_mixerScroll = new QScrollArea(); + m_mixerScroll->setWidgetResizable(true); + m_mixerScroll->setStyleSheet(QStringLiteral( + "QScrollArea { background: #1a1a1e; border: none; }" + "QScrollBar:vertical { background: #1a1a1e; width: 8px; }" + "QScrollBar::handle:vertical { background: #3a3a44; border-radius: 4px; }" + "QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }")); + m_mixerContainer = new QWidget(); + m_mixerContainer->setStyleSheet(QStringLiteral("background: #1a1a1e;")); + auto *mixerLayout = new QVBoxLayout(m_mixerContainer); + mixerLayout->setContentsMargins(4, 4, 4, 4); + mixerLayout->setSpacing(2); + mixerLayout->addStretch(); + m_mixerScroll->setWidget(m_mixerContainer); + m_sidebar->addTab(m_mixerScroll, QStringLiteral("MIXER")); + m_splitter = new QSplitter(Qt::Horizontal); m_splitter->addWidget(m_view); m_splitter->addWidget(m_sidebar); @@ -305,6 +350,17 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client, connect(m_model, &QtNodes::AbstractGraphModel::nodeUpdated, this, &GraphEditorWidget::scheduleSaveLayout); + connect(m_model, &QtNodes::AbstractGraphModel::nodeCreated, this, + [this](QtNodes::NodeId nodeId) { + wireVolumeWidget(nodeId); + rebuildMixerStrips(); + }); + connect(m_model, &QtNodes::AbstractGraphModel::nodeDeleted, this, + [this](QtNodes::NodeId nodeId) { + m_mixerStrips.erase(nodeId); + rebuildMixerStrips(); + }); + m_saveTimer = new QTimer(this); m_saveTimer->setSingleShot(true); m_saveTimer->setInterval(1000); @@ -994,3 +1050,165 @@ void GraphEditorWidget::loadPreset() { QStringLiteral("Failed to load preset.")); } } + +void GraphEditorWidget::wireVolumeWidget(QtNodes::NodeId nodeId) { + auto widget = + m_model->nodeData(nodeId, QtNodes::NodeRole::Widget); + auto *w = widget.value(); + auto *vol = qobject_cast(w); + if (!vol) + return; + + auto capturedId = nodeId; + + connect(vol, &NodeVolumeWidget::volumeChanged, this, + [this, capturedId](int value) { + auto state = m_model->nodeVolumeState(capturedId); + state.volume = static_cast(value) / 100.0f; + m_model->setNodeVolumeState(capturedId, state); + }); + + connect(vol, &NodeVolumeWidget::sliderReleased, this, + [this, capturedId, vol]() { + auto current = m_model->nodeVolumeState(capturedId); + WarpGraphModel::NodeVolumeState previous; + previous.volume = current.volume; + previous.mute = current.mute; + m_scene->undoStack().push( + new VolumeChangeCommand(m_model, capturedId, previous, current)); + }); + + connect(vol, &NodeVolumeWidget::muteToggled, this, + [this, capturedId](bool muted) { + auto previous = m_model->nodeVolumeState(capturedId); + auto next = previous; + next.mute = muted; + m_model->setNodeVolumeState(capturedId, next); + m_scene->undoStack().push( + new VolumeChangeCommand(m_model, capturedId, previous, next)); + }); +} + +void GraphEditorWidget::rebuildMixerStrips() { + if (!m_mixerContainer) + return; + + auto *layout = m_mixerContainer->layout(); + if (!layout) + return; + + while (layout->count() > 0) { + auto *item = layout->takeAt(0); + if (item->widget()) + item->widget()->deleteLater(); + delete item; + } + m_mixerStrips.clear(); + + auto nodeIds = m_model->allNodeIds(); + std::vector sorted(nodeIds.begin(), nodeIds.end()); + std::sort(sorted.begin(), sorted.end()); + + for (auto nodeId : sorted) { + const WarpNodeData *data = m_model->warpNodeData(nodeId); + if (!data) + continue; + + auto *strip = new QWidget(); + strip->setStyleSheet(QStringLiteral( + "QWidget { background: #24242a; border-radius: 4px; }")); + + auto *stripLayout = new QHBoxLayout(strip); + stripLayout->setContentsMargins(6, 4, 6, 4); + stripLayout->setSpacing(6); + + auto *label = new QLabel( + WarpGraphModel::classifyNode(data->info) == WarpNodeType::kApplication + ? QString::fromStdString( + data->info.application_name.empty() + ? data->info.name + : data->info.application_name) + : QString::fromStdString( + data->info.description.empty() + ? data->info.name + : data->info.description)); + label->setFixedWidth(120); + label->setStyleSheet(QStringLiteral( + "QLabel { color: #a0a8b6; font-size: 11px; background: transparent; }")); + label->setToolTip(QString::fromStdString(data->info.name)); + + auto *slider = new ClickSlider(Qt::Horizontal); + slider->setRange(0, 100); + auto state = m_model->nodeVolumeState(nodeId); + slider->setValue(static_cast(state.volume * 100.0f)); + slider->setStyleSheet(QStringLiteral( + "QSlider::groove:horizontal {" + " background: #1a1a1e; border-radius: 3px; height: 6px; }" + "QSlider::handle:horizontal {" + " background: #ecf0f6; border-radius: 5px;" + " width: 10px; margin: -4px 0; }" + "QSlider::sub-page:horizontal {" + " background: #4caf50; border-radius: 3px; }")); + + auto *muteBtn = new QToolButton(); + muteBtn->setText(QStringLiteral("M")); + muteBtn->setCheckable(true); + muteBtn->setChecked(state.mute); + muteBtn->setFixedSize(22, 22); + muteBtn->setStyleSheet(QStringLiteral( + "QToolButton {" + " background: #2e2e36; color: #ecf0f6; border: 1px solid #3a3a44;" + " border-radius: 4px; font-weight: bold; font-size: 11px; }" + "QToolButton:checked {" + " background: #b03030; color: #ecf0f6; border: 1px solid #d04040; }" + "QToolButton:hover { background: #3a3a44; }" + "QToolButton:checked:hover { background: #c04040; }")); + + stripLayout->addWidget(label); + stripLayout->addWidget(slider, 1); + stripLayout->addWidget(muteBtn); + + auto capturedId = nodeId; + + connect(slider, &QSlider::valueChanged, this, + [this, capturedId](int value) { + auto s = m_model->nodeVolumeState(capturedId); + s.volume = static_cast(value) / 100.0f; + m_model->setNodeVolumeState(capturedId, s); + }); + + connect(slider, &QSlider::sliderReleased, this, + [this, capturedId]() { + auto current = m_model->nodeVolumeState(capturedId); + m_scene->undoStack().push( + new VolumeChangeCommand(m_model, capturedId, current, current)); + }); + + connect(muteBtn, &QToolButton::toggled, this, + [this, capturedId](bool muted) { + auto prev = m_model->nodeVolumeState(capturedId); + auto next = prev; + next.mute = muted; + m_model->setNodeVolumeState(capturedId, next); + m_scene->undoStack().push( + new VolumeChangeCommand(m_model, capturedId, prev, next)); + }); + + connect(m_model, &WarpGraphModel::nodeVolumeChanged, slider, + [slider, muteBtn, capturedId](QtNodes::NodeId id, + WarpGraphModel::NodeVolumeState, + WarpGraphModel::NodeVolumeState cur) { + if (id != capturedId) + return; + QSignalBlocker sb(slider); + QSignalBlocker mb(muteBtn); + slider->setValue(static_cast(cur.volume * 100.0f)); + muteBtn->setChecked(cur.mute); + }); + + layout->addWidget(strip); + m_mixerStrips[nodeId] = strip; + } + + static_cast(layout)->addStretch(); +} diff --git a/gui/GraphEditorWidget.h b/gui/GraphEditorWidget.h index d70bdaa..05dfb7e 100644 --- a/gui/GraphEditorWidget.h +++ b/gui/GraphEditorWidget.h @@ -8,6 +8,7 @@ #include #include +#include #include namespace QtNodes { @@ -17,7 +18,9 @@ class GraphicsView; } // namespace QtNodes class WarpGraphModel; +class NodeVolumeWidget; class QLabel; +class QScrollArea; class QSplitter; class QTabWidget; class QTimer; @@ -64,6 +67,8 @@ private: void restoreViewState(); void savePreset(); void loadPreset(); + void wireVolumeWidget(QtNodes::NodeId nodeId); + void rebuildMixerStrips(); struct PendingPasteLink { std::string outNodeName; @@ -87,4 +92,7 @@ private: QJsonObject m_clipboardJson; std::vector m_pendingPasteLinks; QPointF m_lastContextMenuScenePos; + QWidget *m_mixerContainer = nullptr; + QScrollArea *m_mixerScroll = nullptr; + std::unordered_map m_mixerStrips; }; diff --git a/gui/PresetManager.cpp b/gui/PresetManager.cpp index c507d3d..4322839 100644 --- a/gui/PresetManager.cpp +++ b/gui/PresetManager.cpp @@ -84,11 +84,28 @@ bool PresetManager::savePreset(const QString &path, warppipe::Client *client, layoutArray.append(nodeLayout); } + QJsonArray volumesArray; + for (auto qtId : model->allNodeIds()) { + const WarpNodeData *data = model->warpNodeData(qtId); + if (!data) + continue; + auto vs = model->nodeVolumeState(qtId); + if (vs.volume != 1.0f || vs.mute) { + QJsonObject volObj; + volObj["name"] = QString::fromStdString(data->info.name); + volObj["volume"] = static_cast(vs.volume); + volObj["mute"] = vs.mute; + volumesArray.append(volObj); + } + } + QJsonObject root; root["version"] = 1; root["virtual_devices"] = devicesArray; root["routing"] = routingArray; root["layout"] = layoutArray; + if (!volumesArray.isEmpty()) + root["volumes"] = volumesArray; QFileInfo fi(path); QDir dir = fi.absoluteDir(); @@ -173,5 +190,27 @@ bool PresetManager::loadPreset(const QString &path, warppipe::Client *client, } model->refreshFromClient(); + + if (root.contains("volumes")) { + QJsonArray volumesArray = root["volumes"].toArray(); + for (const auto &val : volumesArray) { + QJsonObject obj = val.toObject(); + std::string name = obj["name"].toString().toStdString(); + float volume = static_cast(obj["volume"].toDouble(1.0)); + bool mute = obj["mute"].toBool(false); + + for (auto qtId : model->allNodeIds()) { + const WarpNodeData *data = model->warpNodeData(qtId); + if (data && data->info.name == name) { + WarpGraphModel::NodeVolumeState vs; + vs.volume = volume; + vs.mute = mute; + model->setNodeVolumeState(qtId, vs); + break; + } + } + } + } + return true; } diff --git a/gui/VolumeWidgets.cpp b/gui/VolumeWidgets.cpp new file mode 100644 index 0000000..ec80874 --- /dev/null +++ b/gui/VolumeWidgets.cpp @@ -0,0 +1,105 @@ +#include "VolumeWidgets.h" + +#include +#include +#include +#include + +ClickSlider::ClickSlider(Qt::Orientation orientation, QWidget *parent) + : QSlider(orientation, parent) {} + +void ClickSlider::mousePressEvent(QMouseEvent *event) { + QStyleOptionSlider opt; + initStyleOption(&opt); + QRect grooveRect = + style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderGroove, this); + QRect handleRect = + style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderHandle, this); + + int pos; + int span; + if (orientation() == Qt::Horizontal) { + pos = event->pos().x() - grooveRect.x() - handleRect.width() / 2; + span = grooveRect.width() - handleRect.width(); + } else { + pos = event->pos().y() - grooveRect.y() - handleRect.height() / 2; + span = grooveRect.height() - handleRect.height(); + } + + if (span > 0) { + int val; + if (orientation() == Qt::Horizontal) { + val = QStyle::sliderValueFromPosition(minimum(), maximum(), pos, span, opt.upsideDown); + } else { + val = QStyle::sliderValueFromPosition(minimum(), maximum(), pos, span, !opt.upsideDown); + } + setValue(val); + event->accept(); + } + + QSlider::mousePressEvent(event); +} + +static const char *kSliderStyle = + "QSlider::groove:horizontal {" + " background: #1a1a1e; border-radius: 3px; height: 6px; }" + "QSlider::handle:horizontal {" + " background: #ecf0f6; border-radius: 5px;" + " width: 10px; margin: -4px 0; }" + "QSlider::sub-page:horizontal {" + " background: #4caf50; border-radius: 3px; }"; + +static const char *kMuteBtnStyle = + "QToolButton {" + " background: #2e2e36; color: #ecf0f6; border: 1px solid #3a3a44;" + " border-radius: 4px; padding: 2px 6px; font-weight: bold; font-size: 11px; }" + "QToolButton:checked {" + " background: #b03030; color: #ecf0f6; border: 1px solid #d04040; }" + "QToolButton:hover { background: #3a3a44; }" + "QToolButton:checked:hover { background: #c04040; }"; + +NodeVolumeWidget::NodeVolumeWidget(QWidget *parent) : QWidget(parent) { + setAutoFillBackground(true); + QPalette pal = palette(); + pal.setColor(QPalette::Window, QColor(0x1a, 0x1a, 0x1e)); + setPalette(pal); + + m_slider = new ClickSlider(Qt::Horizontal, this); + m_slider->setRange(0, 100); + m_slider->setValue(100); + m_slider->setFixedWidth(100); + m_slider->setStyleSheet(QString::fromLatin1(kSliderStyle)); + + m_muteBtn = new QToolButton(this); + m_muteBtn->setText(QStringLiteral("M")); + m_muteBtn->setCheckable(true); + m_muteBtn->setFixedSize(22, 22); + m_muteBtn->setStyleSheet(QString::fromLatin1(kMuteBtnStyle)); + + auto *layout = new QHBoxLayout(this); + layout->setContentsMargins(4, 2, 4, 2); + layout->setSpacing(4); + layout->addWidget(m_slider); + layout->addWidget(m_muteBtn); + + connect(m_slider, &QSlider::valueChanged, this, + &NodeVolumeWidget::volumeChanged); + connect(m_slider, &QSlider::sliderReleased, this, + &NodeVolumeWidget::sliderReleased); + connect(m_muteBtn, &QToolButton::toggled, this, + &NodeVolumeWidget::muteToggled); +} + +int NodeVolumeWidget::volume() const { return m_slider->value(); } + +bool NodeVolumeWidget::isMuted() const { return m_muteBtn->isChecked(); } + +void NodeVolumeWidget::setVolume(int value) { + QSignalBlocker blocker(m_slider); + m_slider->setValue(value); +} + +void NodeVolumeWidget::setMuted(bool muted) { + QSignalBlocker blocker(m_muteBtn); + m_muteBtn->setChecked(muted); +} diff --git a/gui/VolumeWidgets.h b/gui/VolumeWidgets.h new file mode 100644 index 0000000..fa61730 --- /dev/null +++ b/gui/VolumeWidgets.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include + +class ClickSlider : public QSlider { + Q_OBJECT +public: + explicit ClickSlider(Qt::Orientation orientation, QWidget *parent = nullptr); + +protected: + void mousePressEvent(QMouseEvent *event) override; +}; + +class NodeVolumeWidget : public QWidget { + Q_OBJECT +public: + explicit NodeVolumeWidget(QWidget *parent = nullptr); + + int volume() const; + bool isMuted() const; + + void setVolume(int value); + void setMuted(bool muted); + +Q_SIGNALS: + void volumeChanged(int value); + void muteToggled(bool muted); + void sliderReleased(); + +private: + ClickSlider *m_slider = nullptr; + QToolButton *m_muteBtn = nullptr; +}; diff --git a/gui/WarpGraphModel.cpp b/gui/WarpGraphModel.cpp index 9db3c33..c32f039 100644 --- a/gui/WarpGraphModel.cpp +++ b/gui/WarpGraphModel.cpp @@ -1,4 +1,5 @@ #include "WarpGraphModel.h" +#include "VolumeWidgets.h" #include #include @@ -178,6 +179,12 @@ QVariant WarpGraphModel::nodeData(QtNodes::NodeId nodeId, WarpNodeType type = classifyNode(data.info); return styleForNode(type, ghost); } + case QtNodes::NodeRole::Widget: { + auto wIt = m_volumeWidgets.find(nodeId); + if (wIt != m_volumeWidgets.end()) + return QVariant::fromValue(wIt->second); + return QVariant::fromValue(static_cast(nullptr)); + } default: return QVariant(); } @@ -290,6 +297,12 @@ bool WarpGraphModel::deleteNode(QtNodes::NodeId const nodeId) { m_nodes.erase(nodeId); m_positions.erase(nodeId); m_sizes.erase(nodeId); + m_volumeStates.erase(nodeId); + auto vwIt = m_volumeWidgets.find(nodeId); + if (vwIt != m_volumeWidgets.end()) { + delete vwIt->second; + m_volumeWidgets.erase(vwIt); + } Q_EMIT nodeDeleted(nodeId); return true; } @@ -457,6 +470,10 @@ void WarpGraphModel::refreshFromClient() { } } + auto *volumeWidget = new NodeVolumeWidget(); + m_volumeWidgets[qtId] = volumeWidget; + m_volumeStates[qtId] = {}; + Q_EMIT nodeCreated(qtId); } @@ -713,6 +730,45 @@ WarpGraphModel::classifyNode(const warppipe::NodeInfo &info) { return WarpNodeType::kUnknown; } +void WarpGraphModel::setNodeVolumeState(QtNodes::NodeId nodeId, + const NodeVolumeState &state) { + if (!nodeExists(nodeId)) + return; + + NodeVolumeState previous = m_volumeStates[nodeId]; + m_volumeStates[nodeId] = state; + + if (m_client) { + auto it = m_nodes.find(nodeId); + if (it != m_nodes.end() && it->second.info.id.value != 0) { +#ifdef WARPPIPE_TESTING + m_client->Test_SetNodeVolume(it->second.info.id, state.volume, state.mute); +#else + m_client->SetNodeVolume(it->second.info.id, state.volume, state.mute); +#endif + } + } + + auto wIt = m_volumeWidgets.find(nodeId); + if (wIt != m_volumeWidgets.end()) { + auto *w = qobject_cast(wIt->second); + if (w) { + w->setVolume(static_cast(state.volume * 100.0f)); + w->setMuted(state.mute); + } + } + + Q_EMIT nodeVolumeChanged(nodeId, previous, state); +} + +WarpGraphModel::NodeVolumeState +WarpGraphModel::nodeVolumeState(QtNodes::NodeId nodeId) const { + auto it = m_volumeStates.find(nodeId); + if (it != m_volumeStates.end()) + return it->second; + return {}; +} + void WarpGraphModel::saveLayout(const QString &path) const { ViewState vs{}; saveLayout(path, vs); @@ -938,6 +994,10 @@ bool WarpGraphModel::loadLayout(const QString &path) { ? m_positions.at(qtId) : QPointF(0, 0); + auto *volumeWidget = new NodeVolumeWidget(); + m_volumeWidgets[qtId] = volumeWidget; + m_volumeStates[qtId] = {}; + Q_EMIT nodeCreated(qtId); } } diff --git a/gui/WarpGraphModel.h b/gui/WarpGraphModel.h index f8658a5..e667eb5 100644 --- a/gui/WarpGraphModel.h +++ b/gui/WarpGraphModel.h @@ -69,6 +69,19 @@ public: uint32_t findPwNodeIdByName(const std::string &name) const; + struct NodeVolumeState { + float volume = 1.0f; + bool mute = false; + }; + + void setNodeVolumeState(QtNodes::NodeId nodeId, const NodeVolumeState &state); + NodeVolumeState nodeVolumeState(QtNodes::NodeId nodeId) const; + +Q_SIGNALS: + void nodeVolumeChanged(QtNodes::NodeId nodeId, NodeVolumeState previous, + NodeVolumeState current); + +public: struct ViewState { double scale; double centerX; @@ -125,4 +138,7 @@ private: std::unordered_map m_savedPositions; std::vector m_pendingGhostConnections; ViewState m_savedViewState{}; + + std::unordered_map m_volumeStates; + std::unordered_map m_volumeWidgets; }; diff --git a/tests/gui/warppipe_gui_tests.cpp b/tests/gui/warppipe_gui_tests.cpp index 94473ae..826ebea 100644 --- a/tests/gui/warppipe_gui_tests.cpp +++ b/tests/gui/warppipe_gui_tests.cpp @@ -2,6 +2,7 @@ #include "../../gui/GraphEditorWidget.h" #include "../../gui/PresetManager.h" +#include "../../gui/VolumeWidgets.h" #include "../../gui/WarpGraphModel.h" #include @@ -963,3 +964,214 @@ TEST_CASE("splitter sizes persist in layout JSON") { QFile::remove(path); } + +TEST_CASE("model volume state defaults to 1.0 and unmuted") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100600, "vol-default", "Audio/Sink")).ok()); + + WarpGraphModel model(tc.client.get()); + model.refreshFromClient(); + + auto qtId = model.qtNodeIdForPw(100600); + REQUIRE(qtId != 0); + + auto state = model.nodeVolumeState(qtId); + REQUIRE(state.volume == Catch::Approx(1.0f)); + REQUIRE_FALSE(state.mute); +} + +TEST_CASE("setNodeVolumeState updates model and calls test helper") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100610, "vol-set", "Audio/Sink")).ok()); + + WarpGraphModel model(tc.client.get()); + model.refreshFromClient(); + + auto qtId = model.qtNodeIdForPw(100610); + REQUIRE(qtId != 0); + + WarpGraphModel::NodeVolumeState ns; + ns.volume = 0.5f; + ns.mute = true; + model.setNodeVolumeState(qtId, ns); + + auto state = model.nodeVolumeState(qtId); + REQUIRE(state.volume == Catch::Approx(0.5f)); + REQUIRE(state.mute); + + auto apiState = tc.client->Test_GetNodeVolume(warppipe::NodeId{100610}); + REQUIRE(apiState.ok()); + REQUIRE(apiState.value.volume == Catch::Approx(0.5f)); + REQUIRE(apiState.value.mute); +} + +TEST_CASE("nodeVolumeChanged signal emitted on state change") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100620, "vol-signal", "Audio/Sink")).ok()); + + WarpGraphModel model(tc.client.get()); + model.refreshFromClient(); + + auto qtId = model.qtNodeIdForPw(100620); + REQUIRE(qtId != 0); + + bool signalFired = false; + QObject::connect(&model, &WarpGraphModel::nodeVolumeChanged, + [&](QtNodes::NodeId id, WarpGraphModel::NodeVolumeState prev, + WarpGraphModel::NodeVolumeState cur) { + if (id == qtId) { + signalFired = true; + REQUIRE(prev.volume == Catch::Approx(1.0f)); + REQUIRE(cur.volume == Catch::Approx(0.3f)); + REQUIRE(cur.mute); + } + }); + + WarpGraphModel::NodeVolumeState ns; + ns.volume = 0.3f; + ns.mute = true; + model.setNodeVolumeState(qtId, ns); + REQUIRE(signalFired); +} + +TEST_CASE("volume widget created for new nodes") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100630, "vol-widget", "Audio/Sink")).ok()); + + WarpGraphModel model(tc.client.get()); + model.refreshFromClient(); + + auto qtId = model.qtNodeIdForPw(100630); + REQUIRE(qtId != 0); + + auto widget = model.nodeData(qtId, QtNodes::NodeRole::Widget); + REQUIRE(widget.isValid()); + auto *w = widget.value(); + REQUIRE(w != nullptr); + auto *vol = qobject_cast(w); + REQUIRE(vol != nullptr); + REQUIRE(vol->volume() == 100); + REQUIRE_FALSE(vol->isMuted()); +} + +TEST_CASE("setNodeVolumeState syncs inline widget") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100640, "vol-sync", "Audio/Sink")).ok()); + + WarpGraphModel model(tc.client.get()); + model.refreshFromClient(); + + auto qtId = model.qtNodeIdForPw(100640); + auto *w = model.nodeData(qtId, QtNodes::NodeRole::Widget).value(); + auto *vol = qobject_cast(w); + REQUIRE(vol != nullptr); + + WarpGraphModel::NodeVolumeState ns; + ns.volume = 0.7f; + ns.mute = true; + model.setNodeVolumeState(qtId, ns); + + REQUIRE(vol->volume() == 70); + REQUIRE(vol->isMuted()); +} + +TEST_CASE("preset saves and loads volume state") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100650, "vol-preset", "Audio/Sink", {}, {}, true)).ok()); + REQUIRE(tc.client->Test_InsertPort( + MakePort(100651, 100650, "FL", true)).ok()); + + WarpGraphModel model(tc.client.get()); + model.refreshFromClient(); + + auto qtId = model.qtNodeIdForPw(100650); + WarpGraphModel::NodeVolumeState ns; + ns.volume = 0.6f; + ns.mute = true; + model.setNodeVolumeState(qtId, ns); + + QString path = QStandardPaths::writableLocation( + QStandardPaths::TempLocation) + + "/warppipe_test_vol_preset.json"; + REQUIRE(PresetManager::savePreset(path, tc.client.get(), &model)); + + QFile file(path); + REQUIRE(file.open(QIODevice::ReadOnly)); + QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + file.close(); + QJsonObject root = doc.object(); + REQUIRE(root.contains("volumes")); + QJsonArray volArr = root["volumes"].toArray(); + bool found = false; + for (const auto &val : volArr) { + QJsonObject obj = val.toObject(); + if (obj["name"].toString() == "vol-preset") { + found = true; + REQUIRE(obj["volume"].toDouble() == Catch::Approx(0.6)); + REQUIRE(obj["mute"].toBool()); + } + } + REQUIRE(found); + + WarpGraphModel model2(tc.client.get()); + model2.refreshFromClient(); + auto qtId2 = model2.qtNodeIdForPw(100650); + auto stateBefore = model2.nodeVolumeState(qtId2); + REQUIRE(stateBefore.volume == Catch::Approx(1.0f)); + + REQUIRE(PresetManager::loadPreset(path, tc.client.get(), &model2)); + auto stateAfter = model2.nodeVolumeState(qtId2); + REQUIRE(stateAfter.volume == Catch::Approx(0.6f)); + REQUIRE(stateAfter.mute); + + QFile::remove(path); +} + +TEST_CASE("volume state cleaned up on node deletion") { + auto tc = TestClient::Create(); + if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } + ensureApp(); + + REQUIRE(tc.client->Test_InsertNode( + MakeNode(100660, "vol-del", "Audio/Sink")).ok()); + + WarpGraphModel model(tc.client.get()); + model.refreshFromClient(); + + auto qtId = model.qtNodeIdForPw(100660); + WarpGraphModel::NodeVolumeState ns; + ns.volume = 0.4f; + model.setNodeVolumeState(qtId, ns); + + REQUIRE(tc.client->Test_RemoveGlobal(100660).ok()); + model.refreshFromClient(); + REQUIRE_FALSE(model.nodeExists(qtId)); + + auto state = model.nodeVolumeState(qtId); + REQUIRE(state.volume == Catch::Approx(1.0f)); + REQUIRE_FALSE(state.mute); +}