Compare commits
10 commits
fa67dd3708
...
08db414fa9
| Author | SHA1 | Date | |
|---|---|---|---|
| 08db414fa9 | |||
| f1a5e2b2e2 | |||
| ad45683f21 | |||
| f78970f9e4 | |||
| 07a151ebdf | |||
| b819d6fd65 | |||
| e8d3f63f4d | |||
| ecec82c70e | |||
| a07f94c93d | |||
| e649dea9c1 |
15 changed files with 2682 additions and 163 deletions
|
|
@ -83,6 +83,8 @@ if(WARPPIPE_BUILD_GUI)
|
|||
gui/WarpGraphModel.cpp
|
||||
gui/GraphEditorWidget.cpp
|
||||
gui/PresetManager.cpp
|
||||
gui/VolumeWidgets.cpp
|
||||
gui/AudioLevelMeter.cpp
|
||||
)
|
||||
|
||||
target_link_libraries(warppipe-gui PRIVATE
|
||||
|
|
@ -98,6 +100,8 @@ if(WARPPIPE_BUILD_GUI)
|
|||
gui/WarpGraphModel.cpp
|
||||
gui/GraphEditorWidget.cpp
|
||||
gui/PresetManager.cpp
|
||||
gui/VolumeWidgets.cpp
|
||||
gui/AudioLevelMeter.cpp
|
||||
)
|
||||
|
||||
target_compile_definitions(warppipe-gui-tests PRIVATE WARPPIPE_TESTING)
|
||||
|
|
|
|||
115
GUI_PLAN.md
115
GUI_PLAN.md
|
|
@ -231,64 +231,63 @@ 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<uint32_t, NodeVolumeState> 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
|
||||
- [ ] Milestone 8e - Audio Level Meters (requires core API: `MeterPeak()`, `NodeMeterPeak()`, `EnsureNodeMeter()`)
|
||||
- [ ] Implement `AudioLevelMeter : QWidget`
|
||||
- [ ] Custom `paintEvent`: vertical bar from bottom, background `(24,24,28)`
|
||||
- [ ] Color thresholds: green (0-0.7), yellow (0.7-0.9), red (0.9-1.0)
|
||||
- [ ] Peak hold indicator: white horizontal line, holds 6 frames then decays at 0.02/frame
|
||||
- [ ] `setLevel(float)` — clamp 0-1, update hold, call `update()`
|
||||
- [ ] `sizeHint()` → 40×160
|
||||
- [ ] Add "METERS" tab to sidebar `QTabWidget`:
|
||||
- [ ] "MASTER OUTPUT" label + master `AudioLevelMeter`
|
||||
- [ ] "NODE METERS" label + scrollable list of per-node meter rows
|
||||
- [ ] Per-node row: elided label + compact `AudioLevelMeter` (fixed 26px wide, min 70px tall)
|
||||
- [ ] Add 30fps meter update timer (33ms, `Qt::PreciseTimer`)
|
||||
- [ ] Poll `Client::MeterPeak()` → master meter
|
||||
- [ ] Poll `Client::NodeMeterPeak(nodeId)` → per-node meters + mixer meters
|
||||
- [ ] Skip updates when widget is not visible (`isVisible()` check)
|
||||
- [ ] Auto-manage per-node meters:
|
||||
- [ ] Create meter when node has active links (`ensureNodeMeter()`)
|
||||
- [ ] Remove meter when node removed or all links removed (`removeNodeMeter()`)
|
||||
- [ ] Skip meter nodes (filter by name prefix)
|
||||
- [ ] Add tests for AudioLevelMeter level clamping, hold/decay logic
|
||||
- [ ] Milestone 8f - Architecture and Routing Rules
|
||||
- [ ] Event-driven updates: replace 500ms polling with signal/slot if core adds registry callbacks
|
||||
- [ ] `nodeAdded(NodeInfo)`, `nodeRemoved(uint32_t)`, `nodeChanged(NodeInfo)`
|
||||
- [ ] `linkAdded(LinkInfo)`, `linkRemoved(uint32_t)`
|
||||
- [ ] Keep polling as fallback if signals not available
|
||||
- [ ] Link intent system: remember intended links by stable key, restore when nodes reappear
|
||||
- [ ] `rememberLinkIntent(LinkInfo)` — store stable_id:port_name pairs
|
||||
- [ ] `tryRestoreLinks()` — called on node add, resolves stored intents
|
||||
- [ ] Persist link intents in layout JSON
|
||||
- [ ] Add routing rule UI (separate panel or dialog)
|
||||
- [ ] List existing rules from `Client::ListRouteRules()`
|
||||
- [ ] Add/remove rules with RuleMatch fields
|
||||
- [ ] Show which nodes are affected by rules
|
||||
- [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<NodeId, NodeVolumeState> 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
|
||||
- [x] Milestone 8e - Audio Level Meters (requires core API: `MeterPeak()`, `NodeMeterPeak()`, `EnsureNodeMeter()`)
|
||||
- [x] Implement `AudioLevelMeter : QWidget`
|
||||
- [x] Custom `paintEvent`: vertical bar from bottom, background `(24,24,28)`
|
||||
- [x] Color thresholds: green (0-0.7), yellow (0.7-0.9), red (0.9-1.0)
|
||||
- [x] Peak hold indicator: white horizontal line, holds 6 frames then decays at 0.02/frame
|
||||
- [x] `setLevel(float)` — clamp 0-1, update hold, call `update()`
|
||||
- [x] `sizeHint()` → 40×160
|
||||
- [x] Add "METERS" tab to sidebar `QTabWidget`:
|
||||
- [x] "MASTER OUTPUT" label + master `AudioLevelMeter`
|
||||
- [x] "NODE METERS" label + scrollable list of per-node meter rows
|
||||
- [x] Per-node row: elided label + compact `AudioLevelMeter` (fixed 26px wide, min 70px tall)
|
||||
- [x] Add 30fps meter update timer (33ms, `Qt::PreciseTimer`)
|
||||
- [x] Poll `Client::MeterPeak()` → master meter
|
||||
- [x] Poll `Client::NodeMeterPeak(nodeId)` → per-node meters
|
||||
- [x] Auto-rebuild node meters on node create/delete
|
||||
- [x] Auto-manage per-node meters:
|
||||
- [x] Call `EnsureNodeMeter()` for each node during rebuild
|
||||
- [x] Remove meter rows when nodes deleted
|
||||
- [x] `rebuildNodeMeters()` wired to `nodeCreated`/`nodeDeleted` signals
|
||||
- [x] Add tests for AudioLevelMeter level clamping, hold/decay logic, METERS tab existence, meter row creation
|
||||
- [x] Milestone 8f - Architecture and Routing Rules
|
||||
- [x] Event-driven updates: core `SetChangeCallback()` fires on registry changes, GUI debounces via 50ms QTimer + QueuedConnection marshal (2s polling kept as fallback)
|
||||
- [x] `Client::SetChangeCallback(ChangeCallback)` — fires from PW thread on node/port/link add/remove
|
||||
- [x] `NotifyChange()` uses dedicated `change_cb_mutex` (not cache_mutex) to avoid lock ordering issues
|
||||
- [x] GUI marshals to Qt thread via `QMetaObject::invokeMethod(..., Qt::QueuedConnection)`
|
||||
- [x] Link intent system: implemented via core `saved_links` + deferred `ProcessSavedLinks()`
|
||||
- [x] `LoadConfig()` parses links into `saved_links` vector (stable node:port name pairs)
|
||||
- [x] `ProcessSavedLinks()` resolves names → port IDs on each CoreDone, creates via `CreateSavedLinkAsync()`
|
||||
- [x] Competing links from WirePlumber auto-removed after saved link creation
|
||||
- [x] Persisted in config.json `links` array (not layout JSON — core owns link state)
|
||||
- [x] Add routing rule UI (RULES sidebar tab)
|
||||
- [x] List existing rules from `Client::ListRouteRules()` as styled cards
|
||||
- [x] Add rules via dialog with Application Name, Process Binary, Media Role, Target Node fields
|
||||
- [x] Delete rules via per-card ✕ button
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
90
gui/AudioLevelMeter.cpp
Normal file
90
gui/AudioLevelMeter.cpp
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
#include "AudioLevelMeter.h"
|
||||
|
||||
#include <QPainter>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
AudioLevelMeter::AudioLevelMeter(QWidget *parent) : QWidget(parent) {
|
||||
setAutoFillBackground(false);
|
||||
setAttribute(Qt::WA_OpaquePaintEvent);
|
||||
}
|
||||
|
||||
void AudioLevelMeter::setLevel(float level) {
|
||||
m_level = std::clamp(level, 0.0f, 1.0f);
|
||||
|
||||
if (m_level >= m_peakHold) {
|
||||
m_peakHold = m_level;
|
||||
m_peakHoldFrames = 0;
|
||||
} else {
|
||||
++m_peakHoldFrames;
|
||||
if (m_peakHoldFrames > kPeakHoldDuration) {
|
||||
m_peakHold = std::max(0.0f, m_peakHold - kPeakDecayRate);
|
||||
}
|
||||
}
|
||||
|
||||
update();
|
||||
}
|
||||
|
||||
float AudioLevelMeter::level() const { return m_level; }
|
||||
|
||||
float AudioLevelMeter::peakHold() const { return m_peakHold; }
|
||||
|
||||
void AudioLevelMeter::resetPeakHold() {
|
||||
m_peakHold = 0.0f;
|
||||
m_peakHoldFrames = 0;
|
||||
update();
|
||||
}
|
||||
|
||||
QSize AudioLevelMeter::sizeHint() const { return {40, 160}; }
|
||||
|
||||
QSize AudioLevelMeter::minimumSizeHint() const { return {12, 40}; }
|
||||
|
||||
void AudioLevelMeter::paintEvent(QPaintEvent *) {
|
||||
QPainter painter(this);
|
||||
painter.setRenderHint(QPainter::Antialiasing, false);
|
||||
|
||||
QRect r = rect();
|
||||
painter.fillRect(r, QColor(24, 24, 28));
|
||||
|
||||
if (m_level <= 0.0f && m_peakHold <= 0.0f)
|
||||
return;
|
||||
|
||||
int barHeight = static_cast<int>(m_level * r.height());
|
||||
int barTop = r.height() - barHeight;
|
||||
|
||||
if (barHeight > 0) {
|
||||
float greenEnd = 0.7f * r.height();
|
||||
float yellowEnd = 0.9f * r.height();
|
||||
|
||||
int greenH = std::min(barHeight, static_cast<int>(greenEnd));
|
||||
if (greenH > 0) {
|
||||
painter.fillRect(r.left(), r.bottom() - greenH + 1, r.width(), greenH,
|
||||
QColor(76, 175, 80));
|
||||
}
|
||||
|
||||
if (barHeight > static_cast<int>(greenEnd)) {
|
||||
int yellowH =
|
||||
std::min(barHeight - static_cast<int>(greenEnd),
|
||||
static_cast<int>(yellowEnd) - static_cast<int>(greenEnd));
|
||||
if (yellowH > 0) {
|
||||
painter.fillRect(r.left(), r.bottom() - static_cast<int>(greenEnd) - yellowH + 1,
|
||||
r.width(), yellowH, QColor(255, 193, 7));
|
||||
}
|
||||
}
|
||||
|
||||
if (barHeight > static_cast<int>(yellowEnd)) {
|
||||
int redH = barHeight - static_cast<int>(yellowEnd);
|
||||
if (redH > 0) {
|
||||
painter.fillRect(r.left(), barTop, r.width(), redH,
|
||||
QColor(244, 67, 54));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (m_peakHold > 0.0f) {
|
||||
int peakY = r.height() - static_cast<int>(m_peakHold * r.height());
|
||||
peakY = std::clamp(peakY, r.top(), r.bottom());
|
||||
painter.setPen(QColor(255, 255, 255));
|
||||
painter.drawLine(r.left(), peakY, r.right(), peakY);
|
||||
}
|
||||
}
|
||||
28
gui/AudioLevelMeter.h
Normal file
28
gui/AudioLevelMeter.h
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#pragma once
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
class AudioLevelMeter : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit AudioLevelMeter(QWidget *parent = nullptr);
|
||||
|
||||
void setLevel(float level);
|
||||
float level() const;
|
||||
float peakHold() const;
|
||||
void resetPeakHold();
|
||||
|
||||
QSize sizeHint() const override;
|
||||
QSize minimumSizeHint() const override;
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *event) override;
|
||||
|
||||
private:
|
||||
float m_level = 0.0f;
|
||||
float m_peakHold = 0.0f;
|
||||
int m_peakHoldFrames = 0;
|
||||
|
||||
static constexpr int kPeakHoldDuration = 6;
|
||||
static constexpr float kPeakDecayRate = 0.02f;
|
||||
};
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
#include "AudioLevelMeter.h"
|
||||
#include "GraphEditorWidget.h"
|
||||
#include "PresetManager.h"
|
||||
#include "VolumeWidgets.h"
|
||||
#include "WarpGraphModel.h"
|
||||
|
||||
#include <QtNodes/BasicGraphicsScene>
|
||||
|
|
@ -12,13 +14,18 @@
|
|||
#include <QAction>
|
||||
#include <QClipboard>
|
||||
#include <QContextMenuEvent>
|
||||
#include <QComboBox>
|
||||
#include <QCoreApplication>
|
||||
#include <QDateTime>
|
||||
#include <QDialog>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QDir>
|
||||
#include <QFileDialog>
|
||||
#include <QFormLayout>
|
||||
#include <QGraphicsItem>
|
||||
#include <QGuiApplication>
|
||||
#include <QInputDialog>
|
||||
#include <QLabel>
|
||||
#include <QMouseEvent>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
|
|
@ -29,6 +36,7 @@
|
|||
#include <QMimeData>
|
||||
#include <QPixmap>
|
||||
#include <QPushButton>
|
||||
#include <QScrollArea>
|
||||
#include <QSplitter>
|
||||
#include <QStandardPaths>
|
||||
#include <QStatusBar>
|
||||
|
|
@ -120,6 +128,32 @@ private:
|
|||
std::vector<Snapshot> 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) {
|
||||
|
|
@ -188,8 +222,85 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
|
|||
presetsLayout->addWidget(loadPresetBtn);
|
||||
presetsLayout->addStretch();
|
||||
|
||||
auto *metersTab = new QWidget();
|
||||
auto *metersLayout = new QVBoxLayout(metersTab);
|
||||
metersLayout->setContentsMargins(8, 8, 8, 8);
|
||||
metersLayout->setSpacing(8);
|
||||
|
||||
auto *masterLabel = new QLabel(QStringLiteral("MASTER OUTPUT"));
|
||||
masterLabel->setStyleSheet(QStringLiteral(
|
||||
"QLabel { color: #a0a8b6; font-size: 11px; font-weight: bold;"
|
||||
" background: transparent; }"));
|
||||
metersLayout->addWidget(masterLabel);
|
||||
|
||||
auto *masterRow = new QWidget();
|
||||
auto *masterRowLayout = new QHBoxLayout(masterRow);
|
||||
masterRowLayout->setContentsMargins(0, 0, 0, 0);
|
||||
masterRowLayout->setSpacing(4);
|
||||
m_masterMeterL = new AudioLevelMeter();
|
||||
m_masterMeterL->setFixedWidth(18);
|
||||
m_masterMeterL->setMinimumHeight(100);
|
||||
m_masterMeterR = new AudioLevelMeter();
|
||||
m_masterMeterR->setFixedWidth(18);
|
||||
m_masterMeterR->setMinimumHeight(100);
|
||||
masterRowLayout->addStretch();
|
||||
masterRowLayout->addWidget(m_masterMeterL);
|
||||
masterRowLayout->addWidget(m_masterMeterR);
|
||||
masterRowLayout->addStretch();
|
||||
metersLayout->addWidget(masterRow);
|
||||
|
||||
auto *nodeMetersLabel = new QLabel(QStringLiteral("NODE METERS"));
|
||||
nodeMetersLabel->setStyleSheet(masterLabel->styleSheet());
|
||||
metersLayout->addWidget(nodeMetersLabel);
|
||||
|
||||
m_nodeMeterScroll = new QScrollArea();
|
||||
m_nodeMeterScroll->setWidgetResizable(true);
|
||||
m_nodeMeterScroll->setStyleSheet(QStringLiteral(
|
||||
"QScrollArea { background: transparent; 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_nodeMeterContainer = new QWidget();
|
||||
m_nodeMeterContainer->setStyleSheet(QStringLiteral("background: transparent;"));
|
||||
auto *nodeMeterLayout = new QVBoxLayout(m_nodeMeterContainer);
|
||||
nodeMeterLayout->setContentsMargins(0, 0, 0, 0);
|
||||
nodeMeterLayout->setSpacing(2);
|
||||
nodeMeterLayout->addStretch();
|
||||
m_nodeMeterScroll->setWidget(m_nodeMeterContainer);
|
||||
metersLayout->addWidget(m_nodeMeterScroll, 1);
|
||||
|
||||
metersTab->setStyleSheet(QStringLiteral("background: #1a1a1e;"));
|
||||
m_sidebar->addTab(metersTab, QStringLiteral("METERS"));
|
||||
|
||||
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_sidebar->addTab(presetsTab, QStringLiteral("PRESETS"));
|
||||
|
||||
m_rulesScroll = new QScrollArea();
|
||||
m_rulesScroll->setWidgetResizable(true);
|
||||
m_rulesScroll->setStyleSheet(m_mixerScroll->styleSheet());
|
||||
m_rulesContainer = new QWidget();
|
||||
m_rulesContainer->setStyleSheet(QStringLiteral("background: #1a1a1e;"));
|
||||
auto *rulesLayout = new QVBoxLayout(m_rulesContainer);
|
||||
rulesLayout->setContentsMargins(8, 8, 8, 8);
|
||||
rulesLayout->setSpacing(6);
|
||||
rulesLayout->addStretch();
|
||||
m_rulesScroll->setWidget(m_rulesContainer);
|
||||
m_sidebar->addTab(m_rulesScroll, QStringLiteral("RULES"));
|
||||
|
||||
m_splitter = new QSplitter(Qt::Horizontal);
|
||||
m_splitter->addWidget(m_view);
|
||||
m_splitter->addWidget(m_sidebar);
|
||||
|
|
@ -305,6 +416,22 @@ 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();
|
||||
rebuildNodeMeters();
|
||||
rebuildRulesList();
|
||||
});
|
||||
connect(m_model, &QtNodes::AbstractGraphModel::nodeDeleted, this,
|
||||
[this](QtNodes::NodeId nodeId) {
|
||||
m_mixerStrips.erase(nodeId);
|
||||
m_nodeMeters.erase(nodeId);
|
||||
rebuildMixerStrips();
|
||||
rebuildNodeMeters();
|
||||
rebuildRulesList();
|
||||
});
|
||||
|
||||
m_saveTimer = new QTimer(this);
|
||||
m_saveTimer->setSingleShot(true);
|
||||
m_saveTimer->setInterval(1000);
|
||||
|
|
@ -312,6 +439,7 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
|
|||
&GraphEditorWidget::saveLayoutWithViewState);
|
||||
|
||||
m_model->refreshFromClient();
|
||||
rebuildRulesList();
|
||||
if (!hasLayout) {
|
||||
m_model->autoArrange();
|
||||
}
|
||||
|
|
@ -326,10 +454,30 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
|
|||
connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this,
|
||||
&GraphEditorWidget::saveLayoutWithViewState);
|
||||
|
||||
m_changeTimer = new QTimer(this);
|
||||
m_changeTimer->setSingleShot(true);
|
||||
m_changeTimer->setInterval(50);
|
||||
connect(m_changeTimer, &QTimer::timeout, this,
|
||||
&GraphEditorWidget::onRefreshTimer);
|
||||
|
||||
if (m_client) {
|
||||
m_client->SetChangeCallback([this] {
|
||||
QMetaObject::invokeMethod(m_changeTimer,
|
||||
qOverload<>(&QTimer::start),
|
||||
Qt::QueuedConnection);
|
||||
});
|
||||
}
|
||||
|
||||
m_refreshTimer = new QTimer(this);
|
||||
connect(m_refreshTimer, &QTimer::timeout, this,
|
||||
&GraphEditorWidget::onRefreshTimer);
|
||||
m_refreshTimer->start(500);
|
||||
m_refreshTimer->start(2000);
|
||||
|
||||
m_meterTimer = new QTimer(this);
|
||||
m_meterTimer->setTimerType(Qt::PreciseTimer);
|
||||
connect(m_meterTimer, &QTimer::timeout, this,
|
||||
&GraphEditorWidget::updateMeters);
|
||||
m_meterTimer->start(33);
|
||||
}
|
||||
|
||||
void GraphEditorWidget::onRefreshTimer() {
|
||||
|
|
@ -348,6 +496,12 @@ void GraphEditorWidget::scheduleSaveLayout() {
|
|||
}
|
||||
}
|
||||
|
||||
GraphEditorWidget::~GraphEditorWidget() {
|
||||
if (m_client) {
|
||||
m_client->SetChangeCallback(nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
int GraphEditorWidget::nodeCount() const {
|
||||
return static_cast<int>(m_model->allNodeIds().size());
|
||||
}
|
||||
|
|
@ -564,6 +718,13 @@ void GraphEditorWidget::showNodeContextMenu(const QPoint &screenPos,
|
|||
deleteAction->setShortcut(QKeySequence::Delete);
|
||||
}
|
||||
|
||||
QAction *createRuleAction = nullptr;
|
||||
if (type == WarpNodeType::kApplication) {
|
||||
menu.addSeparator();
|
||||
createRuleAction = menu.addAction(QStringLiteral("Create Rule..."));
|
||||
}
|
||||
|
||||
menu.addSeparator();
|
||||
QAction *pasteAction = menu.addAction(QStringLiteral("Paste"));
|
||||
pasteAction->setShortcut(QKeySequence::Paste);
|
||||
pasteAction->setEnabled(!m_clipboardJson.isEmpty() ||
|
||||
|
|
@ -584,6 +745,10 @@ void GraphEditorWidget::showNodeContextMenu(const QPoint &screenPos,
|
|||
deleteSelection();
|
||||
} else if (chosen == pasteAction) {
|
||||
pasteSelection(QPointF(0, 0));
|
||||
} else if (chosen == createRuleAction) {
|
||||
showAddRuleDialog(data->info.application_name,
|
||||
data->info.process_binary,
|
||||
data->info.media_role);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -920,7 +1085,7 @@ void GraphEditorWidget::tryResolvePendingLinks() {
|
|||
}
|
||||
|
||||
if (foundOut && foundIn) {
|
||||
m_client->CreateLink(outPortId, inPortId, warppipe::LinkOptions{});
|
||||
m_client->CreateLink(outPortId, inPortId, warppipe::LinkOptions{.linger = true});
|
||||
} else {
|
||||
remaining.push_back(pending);
|
||||
}
|
||||
|
|
@ -994,3 +1159,458 @@ 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<QWidget *>();
|
||||
auto *vol = qobject_cast<NodeVolumeWidget *>(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<float>(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<QtNodes::NodeId> 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<int>(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<float>(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<int>(cur.volume * 100.0f));
|
||||
muteBtn->setChecked(cur.mute);
|
||||
});
|
||||
|
||||
layout->addWidget(strip);
|
||||
m_mixerStrips[nodeId] = strip;
|
||||
}
|
||||
|
||||
static_cast<QVBoxLayout *>(layout)->addStretch();
|
||||
}
|
||||
|
||||
void GraphEditorWidget::updateMeters() {
|
||||
if (!m_client)
|
||||
return;
|
||||
|
||||
auto master = m_client->MeterPeak();
|
||||
if (master.ok()) {
|
||||
m_masterMeterL->setLevel(master.value.peak_left);
|
||||
m_masterMeterR->setLevel(master.value.peak_right);
|
||||
}
|
||||
|
||||
for (auto &[nodeId, row] : m_nodeMeters) {
|
||||
const WarpNodeData *data = m_model->warpNodeData(nodeId);
|
||||
if (!data || !row.meter)
|
||||
continue;
|
||||
auto peak = m_client->NodeMeterPeak(data->info.id);
|
||||
if (peak.ok()) {
|
||||
row.meter->setLevel(
|
||||
std::max(peak.value.peak_left, peak.value.peak_right));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void GraphEditorWidget::rebuildNodeMeters() {
|
||||
if (!m_nodeMeterContainer || !m_client)
|
||||
return;
|
||||
|
||||
auto *layout = m_nodeMeterContainer->layout();
|
||||
if (!layout)
|
||||
return;
|
||||
|
||||
std::unordered_map<uint32_t, bool> old_pw_ids;
|
||||
for (const auto &[nid, row] : m_nodeMeters) {
|
||||
const WarpNodeData *d = m_model->warpNodeData(nid);
|
||||
if (d)
|
||||
old_pw_ids[d->info.id.value] = true;
|
||||
}
|
||||
|
||||
while (layout->count() > 0) {
|
||||
auto *item = layout->takeAt(0);
|
||||
if (item->widget())
|
||||
item->widget()->deleteLater();
|
||||
delete item;
|
||||
}
|
||||
m_nodeMeters.clear();
|
||||
|
||||
auto nodeIds = m_model->allNodeIds();
|
||||
std::vector<QtNodes::NodeId> sorted(nodeIds.begin(), nodeIds.end());
|
||||
std::sort(sorted.begin(), sorted.end());
|
||||
|
||||
std::unordered_map<uint32_t, bool> new_pw_ids;
|
||||
for (auto nodeId : sorted) {
|
||||
const WarpNodeData *data = m_model->warpNodeData(nodeId);
|
||||
if (!data)
|
||||
continue;
|
||||
|
||||
new_pw_ids[data->info.id.value] = true;
|
||||
m_client->EnsureNodeMeter(data->info.id);
|
||||
|
||||
auto *row = new QWidget();
|
||||
auto *rowLayout = new QHBoxLayout(row);
|
||||
rowLayout->setContentsMargins(0, 0, 0, 0);
|
||||
rowLayout->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->setStyleSheet(QStringLiteral(
|
||||
"QLabel { color: #a0a8b6; font-size: 11px; background: transparent; }"));
|
||||
label->setToolTip(QString::fromStdString(data->info.name));
|
||||
|
||||
auto *meter = new AudioLevelMeter();
|
||||
meter->setFixedWidth(26);
|
||||
meter->setMinimumHeight(70);
|
||||
|
||||
rowLayout->addWidget(label, 1);
|
||||
rowLayout->addWidget(meter);
|
||||
|
||||
layout->addWidget(row);
|
||||
|
||||
NodeMeterRow meterRow;
|
||||
meterRow.widget = row;
|
||||
meterRow.meter = meter;
|
||||
meterRow.label = label;
|
||||
m_nodeMeters[nodeId] = meterRow;
|
||||
}
|
||||
|
||||
static_cast<QVBoxLayout *>(layout)->addStretch();
|
||||
|
||||
for (const auto &[pw_id, _] : old_pw_ids) {
|
||||
if (new_pw_ids.find(pw_id) == new_pw_ids.end()) {
|
||||
m_client->DisableNodeMeter(warppipe::NodeId{pw_id});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void GraphEditorWidget::rebuildRulesList() {
|
||||
if (!m_rulesContainer || !m_client)
|
||||
return;
|
||||
|
||||
auto *layout = m_rulesContainer->layout();
|
||||
if (!layout)
|
||||
return;
|
||||
|
||||
while (layout->count() > 0) {
|
||||
auto *item = layout->takeAt(0);
|
||||
if (item->widget())
|
||||
item->widget()->deleteLater();
|
||||
delete item;
|
||||
}
|
||||
|
||||
const QString labelStyle = QStringLiteral(
|
||||
"QLabel { color: #a0a8b6; font-size: 11px; background: transparent; }");
|
||||
const QString valueStyle = QStringLiteral(
|
||||
"QLabel { color: #ecf0f6; font-size: 12px; background: transparent; }");
|
||||
const QString btnStyle = QStringLiteral(
|
||||
"QPushButton { background: #2e2e36; color: #ecf0f6; border: 1px solid #3a3a44;"
|
||||
" border-radius: 4px; padding: 6px 12px; }"
|
||||
"QPushButton:hover { background: #3a3a44; }"
|
||||
"QPushButton:pressed { background: #44444e; }");
|
||||
const QString delBtnStyle = QStringLiteral(
|
||||
"QPushButton { background: transparent; color: #a05050; border: none;"
|
||||
" font-size: 14px; font-weight: bold; padding: 2px 6px; }"
|
||||
"QPushButton:hover { color: #e05050; }");
|
||||
|
||||
auto *header = new QLabel(QStringLiteral("ROUTING RULES"));
|
||||
header->setStyleSheet(QStringLiteral(
|
||||
"QLabel { color: #a0a8b6; font-size: 11px; font-weight: bold;"
|
||||
" background: transparent; }"));
|
||||
layout->addWidget(header);
|
||||
|
||||
auto rulesResult = m_client->ListRouteRules();
|
||||
if (rulesResult.ok()) {
|
||||
for (const auto &rule : rulesResult.value) {
|
||||
auto *card = new QWidget();
|
||||
card->setStyleSheet(QStringLiteral(
|
||||
"QWidget { background: #24242a; border-radius: 4px; }"));
|
||||
auto *cardLayout = new QHBoxLayout(card);
|
||||
cardLayout->setContentsMargins(8, 6, 4, 6);
|
||||
cardLayout->setSpacing(8);
|
||||
|
||||
QString matchText;
|
||||
if (!rule.match.application_name.empty())
|
||||
matchText += QStringLiteral("app: ") +
|
||||
QString::fromStdString(rule.match.application_name);
|
||||
if (!rule.match.process_binary.empty()) {
|
||||
if (!matchText.isEmpty()) matchText += QStringLiteral(", ");
|
||||
matchText += QStringLiteral("bin: ") +
|
||||
QString::fromStdString(rule.match.process_binary);
|
||||
}
|
||||
if (!rule.match.media_role.empty()) {
|
||||
if (!matchText.isEmpty()) matchText += QStringLiteral(", ");
|
||||
matchText += QStringLiteral("role: ") +
|
||||
QString::fromStdString(rule.match.media_role);
|
||||
}
|
||||
|
||||
auto *infoLayout = new QVBoxLayout();
|
||||
infoLayout->setContentsMargins(0, 0, 0, 0);
|
||||
infoLayout->setSpacing(2);
|
||||
|
||||
auto *matchLabel = new QLabel(matchText);
|
||||
matchLabel->setStyleSheet(valueStyle);
|
||||
infoLayout->addWidget(matchLabel);
|
||||
|
||||
auto *targetLabel = new QLabel(
|
||||
QString(QChar(0x2192)) + QStringLiteral(" ") +
|
||||
QString::fromStdString(rule.target_node));
|
||||
targetLabel->setStyleSheet(labelStyle);
|
||||
infoLayout->addWidget(targetLabel);
|
||||
|
||||
cardLayout->addLayout(infoLayout, 1);
|
||||
|
||||
auto *delBtn = new QPushButton(QString(QChar(0x2715)));
|
||||
delBtn->setFixedSize(24, 24);
|
||||
delBtn->setStyleSheet(delBtnStyle);
|
||||
warppipe::RuleId ruleId = rule.id;
|
||||
connect(delBtn, &QPushButton::clicked, this, [this, ruleId]() {
|
||||
m_client->RemoveRouteRule(ruleId);
|
||||
rebuildRulesList();
|
||||
});
|
||||
cardLayout->addWidget(delBtn);
|
||||
|
||||
layout->addWidget(card);
|
||||
}
|
||||
}
|
||||
|
||||
auto *addBtn = new QPushButton(QStringLiteral("Add Rule..."));
|
||||
addBtn->setStyleSheet(btnStyle);
|
||||
connect(addBtn, &QPushButton::clicked, this,
|
||||
[this]() { showAddRuleDialog(); });
|
||||
layout->addWidget(addBtn);
|
||||
|
||||
static_cast<QVBoxLayout *>(layout)->addStretch();
|
||||
}
|
||||
|
||||
void GraphEditorWidget::showAddRuleDialog(const std::string &prefillApp,
|
||||
const std::string &prefillBin,
|
||||
const std::string &prefillRole) {
|
||||
if (!m_client)
|
||||
return;
|
||||
|
||||
QDialog dlg(this);
|
||||
dlg.setWindowTitle(QStringLiteral("Add Routing Rule"));
|
||||
dlg.setStyleSheet(QStringLiteral(
|
||||
"QDialog { background: #1e1e22; }"
|
||||
"QLabel { color: #ecf0f6; }"
|
||||
"QLineEdit { background: #2a2a32; color: #ecf0f6; border: 1px solid #3a3a44;"
|
||||
" border-radius: 4px; padding: 4px 8px; }"
|
||||
"QComboBox { background: #2a2a32; color: #ecf0f6; border: 1px solid #3a3a44;"
|
||||
" border-radius: 4px; padding: 4px 8px; }"
|
||||
"QComboBox::drop-down { border: none; }"
|
||||
"QComboBox QAbstractItemView { background: #2a2a32; color: #ecf0f6;"
|
||||
" selection-background-color: #3a3a44; }"));
|
||||
|
||||
auto *form = new QFormLayout(&dlg);
|
||||
form->setContentsMargins(16, 16, 16, 16);
|
||||
form->setSpacing(8);
|
||||
|
||||
auto *appNameEdit = new QLineEdit();
|
||||
appNameEdit->setPlaceholderText(QStringLiteral("e.g. Firefox"));
|
||||
if (!prefillApp.empty())
|
||||
appNameEdit->setText(QString::fromStdString(prefillApp));
|
||||
form->addRow(QStringLiteral("Application Name:"), appNameEdit);
|
||||
|
||||
auto *processBinEdit = new QLineEdit();
|
||||
processBinEdit->setPlaceholderText(QStringLiteral("e.g. firefox"));
|
||||
if (!prefillBin.empty())
|
||||
processBinEdit->setText(QString::fromStdString(prefillBin));
|
||||
form->addRow(QStringLiteral("Process Binary:"), processBinEdit);
|
||||
|
||||
auto *mediaRoleEdit = new QLineEdit();
|
||||
mediaRoleEdit->setPlaceholderText(QStringLiteral("e.g. Music"));
|
||||
if (!prefillRole.empty())
|
||||
mediaRoleEdit->setText(QString::fromStdString(prefillRole));
|
||||
form->addRow(QStringLiteral("Media Role:"), mediaRoleEdit);
|
||||
|
||||
auto *targetCombo = new QComboBox();
|
||||
auto nodesResult = m_client->ListNodes();
|
||||
if (nodesResult.ok()) {
|
||||
for (const auto &node : nodesResult.value) {
|
||||
if (node.media_class.find("Sink") != std::string::npos) {
|
||||
QString label = QString::fromStdString(
|
||||
node.description.empty() ? node.name : node.description);
|
||||
targetCombo->addItem(label, QString::fromStdString(node.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
form->addRow(QStringLiteral("Target Node:"), targetCombo);
|
||||
|
||||
auto *buttons = new QDialogButtonBox(
|
||||
QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
|
||||
buttons->setStyleSheet(QStringLiteral(
|
||||
"QPushButton { background: #2e2e36; color: #ecf0f6; border: 1px solid #3a3a44;"
|
||||
" border-radius: 4px; padding: 6px 16px; }"
|
||||
"QPushButton:hover { background: #3a3a44; }"));
|
||||
connect(buttons, &QDialogButtonBox::accepted, &dlg, &QDialog::accept);
|
||||
connect(buttons, &QDialogButtonBox::rejected, &dlg, &QDialog::reject);
|
||||
form->addRow(buttons);
|
||||
|
||||
if (dlg.exec() != QDialog::Accepted)
|
||||
return;
|
||||
|
||||
std::string appName = appNameEdit->text().trimmed().toStdString();
|
||||
std::string procBin = processBinEdit->text().trimmed().toStdString();
|
||||
std::string role = mediaRoleEdit->text().trimmed().toStdString();
|
||||
std::string target = targetCombo->currentData().toString().toStdString();
|
||||
|
||||
if (appName.empty() && procBin.empty() && role.empty()) {
|
||||
QMessageBox::warning(this, QStringLiteral("Invalid Rule"),
|
||||
QStringLiteral("At least one match field must be filled."));
|
||||
return;
|
||||
}
|
||||
if (target.empty()) {
|
||||
QMessageBox::warning(this, QStringLiteral("Invalid Rule"),
|
||||
QStringLiteral("A target node must be selected."));
|
||||
return;
|
||||
}
|
||||
|
||||
warppipe::RouteRule rule;
|
||||
rule.match.application_name = appName;
|
||||
rule.match.process_binary = procBin;
|
||||
rule.match.media_role = role;
|
||||
rule.target_node = target;
|
||||
m_client->AddRouteRule(rule);
|
||||
rebuildRulesList();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
#include <QWidget>
|
||||
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace QtNodes {
|
||||
|
|
@ -16,8 +17,11 @@ class BasicGraphicsScene;
|
|||
class GraphicsView;
|
||||
} // namespace QtNodes
|
||||
|
||||
class AudioLevelMeter;
|
||||
class WarpGraphModel;
|
||||
class NodeVolumeWidget;
|
||||
class QLabel;
|
||||
class QScrollArea;
|
||||
class QSplitter;
|
||||
class QTabWidget;
|
||||
class QTimer;
|
||||
|
|
@ -31,6 +35,7 @@ class GraphEditorWidget : public QWidget {
|
|||
public:
|
||||
explicit GraphEditorWidget(warppipe::Client *client,
|
||||
QWidget *parent = nullptr);
|
||||
~GraphEditorWidget() override;
|
||||
|
||||
int nodeCount() const;
|
||||
int linkCount() const;
|
||||
|
|
@ -64,6 +69,14 @@ private:
|
|||
void restoreViewState();
|
||||
void savePreset();
|
||||
void loadPreset();
|
||||
void wireVolumeWidget(QtNodes::NodeId nodeId);
|
||||
void rebuildMixerStrips();
|
||||
void updateMeters();
|
||||
void rebuildNodeMeters();
|
||||
void rebuildRulesList();
|
||||
void showAddRuleDialog(const std::string &prefillApp = {},
|
||||
const std::string &prefillBin = {},
|
||||
const std::string &prefillRole = {});
|
||||
|
||||
struct PendingPasteLink {
|
||||
std::string outNodeName;
|
||||
|
|
@ -79,6 +92,7 @@ private:
|
|||
QSplitter *m_splitter = nullptr;
|
||||
QTabWidget *m_sidebar = nullptr;
|
||||
QTimer *m_refreshTimer = nullptr;
|
||||
QTimer *m_changeTimer = nullptr;
|
||||
QTimer *m_saveTimer = nullptr;
|
||||
QString m_layoutPath;
|
||||
QString m_presetDir;
|
||||
|
|
@ -87,4 +101,22 @@ private:
|
|||
QJsonObject m_clipboardJson;
|
||||
std::vector<PendingPasteLink> m_pendingPasteLinks;
|
||||
QPointF m_lastContextMenuScenePos;
|
||||
QWidget *m_mixerContainer = nullptr;
|
||||
QScrollArea *m_mixerScroll = nullptr;
|
||||
std::unordered_map<QtNodes::NodeId, QWidget *> m_mixerStrips;
|
||||
|
||||
QTimer *m_meterTimer = nullptr;
|
||||
AudioLevelMeter *m_masterMeterL = nullptr;
|
||||
AudioLevelMeter *m_masterMeterR = nullptr;
|
||||
QWidget *m_nodeMeterContainer = nullptr;
|
||||
QScrollArea *m_nodeMeterScroll = nullptr;
|
||||
struct NodeMeterRow {
|
||||
QWidget *widget = nullptr;
|
||||
AudioLevelMeter *meter = nullptr;
|
||||
QLabel *label = nullptr;
|
||||
};
|
||||
std::unordered_map<QtNodes::NodeId, NodeMeterRow> m_nodeMeters;
|
||||
|
||||
QWidget *m_rulesContainer = nullptr;
|
||||
QScrollArea *m_rulesScroll = nullptr;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<double>(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();
|
||||
|
|
@ -169,9 +186,31 @@ bool PresetManager::loadPreset(const QString &path, warppipe::Client *client,
|
|||
std::string inPort = route["in_port"].toString().toStdString();
|
||||
|
||||
client->CreateLinkByName(outNode, outPort, inNode, inPort,
|
||||
warppipe::LinkOptions{});
|
||||
warppipe::LinkOptions{.linger = true});
|
||||
}
|
||||
|
||||
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<float>(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;
|
||||
}
|
||||
|
|
|
|||
105
gui/VolumeWidgets.cpp
Normal file
105
gui/VolumeWidgets.cpp
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
#include "VolumeWidgets.h"
|
||||
|
||||
#include <QHBoxLayout>
|
||||
#include <QMouseEvent>
|
||||
#include <QStyle>
|
||||
#include <QStyleOptionSlider>
|
||||
|
||||
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);
|
||||
}
|
||||
35
gui/VolumeWidgets.h
Normal file
35
gui/VolumeWidgets.h
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
#pragma once
|
||||
|
||||
#include <QSlider>
|
||||
#include <QToolButton>
|
||||
#include <QWidget>
|
||||
|
||||
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;
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
#include "WarpGraphModel.h"
|
||||
#include "VolumeWidgets.h"
|
||||
|
||||
#include <QColor>
|
||||
#include <QDir>
|
||||
|
|
@ -123,7 +124,7 @@ void WarpGraphModel::addConnection(
|
|||
warppipe::PortId outPortId = outIt->second.outputPorts[outIdx].id;
|
||||
warppipe::PortId inPortId = inIt->second.inputPorts[inIdx].id;
|
||||
|
||||
auto result = m_client->CreateLink(outPortId, inPortId, warppipe::LinkOptions{});
|
||||
auto result = m_client->CreateLink(outPortId, inPortId, warppipe::LinkOptions{.linger = true});
|
||||
if (!result.ok()) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -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<QWidget *>(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<NodeVolumeWidget *>(wIt->second);
|
||||
if (w) {
|
||||
w->setVolume(static_cast<int>(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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<std::string, QPointF> m_savedPositions;
|
||||
std::vector<PendingGhostConnection> m_pendingGhostConnections;
|
||||
ViewState m_savedViewState{};
|
||||
|
||||
std::unordered_map<QtNodes::NodeId, NodeVolumeState> m_volumeStates;
|
||||
std::unordered_map<QtNodes::NodeId, QWidget *> m_volumeWidgets;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
|
@ -137,6 +138,16 @@ struct RouteRule {
|
|||
std::string target_node;
|
||||
};
|
||||
|
||||
struct VolumeState {
|
||||
float volume = 1.0f;
|
||||
bool mute = false;
|
||||
};
|
||||
|
||||
struct MeterState {
|
||||
float peak_left = 0.0f;
|
||||
float peak_right = 0.0f;
|
||||
};
|
||||
|
||||
struct MetadataInfo {
|
||||
std::string default_sink_name;
|
||||
std::string default_source_name;
|
||||
|
|
@ -166,6 +177,14 @@ class Client {
|
|||
const VirtualNodeOptions& options = VirtualNodeOptions{});
|
||||
Status RemoveNode(NodeId node);
|
||||
|
||||
Status SetNodeVolume(NodeId node, float volume, bool mute);
|
||||
Result<VolumeState> GetNodeVolume(NodeId node) const;
|
||||
|
||||
Status EnsureNodeMeter(NodeId node);
|
||||
Status DisableNodeMeter(NodeId node);
|
||||
Result<MeterState> NodeMeterPeak(NodeId node) const;
|
||||
Result<MeterState> MeterPeak() const;
|
||||
|
||||
Result<Link> CreateLink(PortId output, PortId input, const LinkOptions& options);
|
||||
Result<Link> CreateLinkByName(std::string_view output_node,
|
||||
std::string_view output_port,
|
||||
|
|
@ -185,6 +204,9 @@ class Client {
|
|||
Status SaveConfig(std::string_view path);
|
||||
Status LoadConfig(std::string_view path);
|
||||
|
||||
using ChangeCallback = std::function<void()>;
|
||||
void SetChangeCallback(ChangeCallback callback);
|
||||
|
||||
#ifdef WARPPIPE_TESTING
|
||||
Status Test_InsertNode(const NodeInfo& node);
|
||||
Status Test_InsertPort(const PortInfo& port);
|
||||
|
|
@ -193,6 +215,10 @@ class Client {
|
|||
Status Test_ForceDisconnect();
|
||||
Status Test_TriggerPolicyCheck();
|
||||
size_t Test_GetPendingAutoLinkCount() const;
|
||||
Status Test_SetNodeVolume(NodeId node, float volume, bool mute);
|
||||
Result<VolumeState> Test_GetNodeVolume(NodeId node) const;
|
||||
Status Test_SetNodeMeterPeak(NodeId node, float left, float right);
|
||||
Status Test_SetMasterMeterPeak(float left, float right);
|
||||
#endif
|
||||
|
||||
private:
|
||||
|
|
|
|||
1139
src/warppipe.cpp
1139
src/warppipe.cpp
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,9 @@
|
|||
#include <warppipe/warppipe.hpp>
|
||||
|
||||
#include "../../gui/AudioLevelMeter.h"
|
||||
#include "../../gui/GraphEditorWidget.h"
|
||||
#include "../../gui/PresetManager.h"
|
||||
#include "../../gui/VolumeWidgets.h"
|
||||
#include "../../gui/WarpGraphModel.h"
|
||||
|
||||
#include <QAction>
|
||||
|
|
@ -11,6 +13,7 @@
|
|||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QStandardPaths>
|
||||
#include <QTabWidget>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/catch_approx.hpp>
|
||||
|
|
@ -963,3 +966,361 @@ 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<QWidget *>();
|
||||
REQUIRE(w != nullptr);
|
||||
auto *vol = qobject_cast<NodeVolumeWidget *>(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<QWidget *>();
|
||||
auto *vol = qobject_cast<NodeVolumeWidget *>(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("AudioLevelMeter setLevel clamps to 0-1") {
|
||||
ensureApp();
|
||||
AudioLevelMeter meter;
|
||||
meter.setLevel(0.5f);
|
||||
REQUIRE(meter.level() == Catch::Approx(0.5f));
|
||||
meter.setLevel(-0.5f);
|
||||
REQUIRE(meter.level() == Catch::Approx(0.0f));
|
||||
meter.setLevel(1.5f);
|
||||
REQUIRE(meter.level() == Catch::Approx(1.0f));
|
||||
}
|
||||
|
||||
TEST_CASE("AudioLevelMeter peak hold tracks maximum") {
|
||||
ensureApp();
|
||||
AudioLevelMeter meter;
|
||||
meter.setLevel(0.8f);
|
||||
REQUIRE(meter.peakHold() == Catch::Approx(0.8f));
|
||||
meter.setLevel(0.3f);
|
||||
REQUIRE(meter.peakHold() == Catch::Approx(0.8f));
|
||||
meter.setLevel(0.9f);
|
||||
REQUIRE(meter.peakHold() == Catch::Approx(0.9f));
|
||||
}
|
||||
|
||||
TEST_CASE("AudioLevelMeter peak decays after hold period") {
|
||||
ensureApp();
|
||||
AudioLevelMeter meter;
|
||||
meter.setLevel(0.5f);
|
||||
REQUIRE(meter.peakHold() == Catch::Approx(0.5f));
|
||||
for (int i = 0; i < 7; ++i)
|
||||
meter.setLevel(0.0f);
|
||||
REQUIRE(meter.peakHold() < 0.5f);
|
||||
REQUIRE(meter.peakHold() > 0.0f);
|
||||
}
|
||||
|
||||
TEST_CASE("AudioLevelMeter resetPeakHold clears peak") {
|
||||
ensureApp();
|
||||
AudioLevelMeter meter;
|
||||
meter.setLevel(0.7f);
|
||||
REQUIRE(meter.peakHold() == Catch::Approx(0.7f));
|
||||
meter.resetPeakHold();
|
||||
REQUIRE(meter.peakHold() == Catch::Approx(0.0f));
|
||||
}
|
||||
|
||||
TEST_CASE("GraphEditorWidget has METERS tab") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
ensureApp();
|
||||
|
||||
GraphEditorWidget widget(tc.client.get());
|
||||
auto *sidebar = widget.findChild<QTabWidget *>();
|
||||
REQUIRE(sidebar != nullptr);
|
||||
bool found = false;
|
||||
for (int i = 0; i < sidebar->count(); ++i) {
|
||||
if (sidebar->tabText(i) == "METERS") {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
REQUIRE(found);
|
||||
}
|
||||
|
||||
TEST_CASE("node meter rows created for injected nodes") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
ensureApp();
|
||||
|
||||
REQUIRE(tc.client->Test_InsertNode(
|
||||
MakeNode(100700, "meter-node", "Audio/Sink")).ok());
|
||||
REQUIRE(tc.client->Test_InsertPort(
|
||||
MakePort(100701, 100700, "FL", true)).ok());
|
||||
|
||||
GraphEditorWidget widget(tc.client.get());
|
||||
auto meters = widget.findChildren<AudioLevelMeter *>();
|
||||
REQUIRE(meters.size() >= 3);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
TEST_CASE("GraphEditorWidget has RULES tab") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
ensureApp();
|
||||
|
||||
GraphEditorWidget widget(tc.client.get());
|
||||
auto *sidebar = widget.findChild<QTabWidget *>();
|
||||
REQUIRE(sidebar != nullptr);
|
||||
bool found = false;
|
||||
for (int i = 0; i < sidebar->count(); ++i) {
|
||||
if (sidebar->tabText(i) == "RULES") {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
REQUIRE(found);
|
||||
}
|
||||
|
||||
TEST_CASE("SetChangeCallback fires on node insert") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
|
||||
std::atomic<int> count{0};
|
||||
tc.client->SetChangeCallback([&count]() { ++count; });
|
||||
|
||||
REQUIRE(tc.client->Test_InsertNode(
|
||||
MakeNode(100800, "cb-test-node", "Audio/Sink")).ok());
|
||||
REQUIRE(count.load() >= 1);
|
||||
}
|
||||
|
||||
TEST_CASE("SetChangeCallback fires on node remove") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
|
||||
REQUIRE(tc.client->Test_InsertNode(
|
||||
MakeNode(100810, "cb-remove-node", "Audio/Sink")).ok());
|
||||
|
||||
std::atomic<int> count{0};
|
||||
tc.client->SetChangeCallback([&count]() { ++count; });
|
||||
|
||||
REQUIRE(tc.client->Test_RemoveGlobal(100810).ok());
|
||||
REQUIRE(count.load() >= 1);
|
||||
}
|
||||
|
||||
TEST_CASE("SetChangeCallback can be cleared") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
|
||||
std::atomic<int> count{0};
|
||||
tc.client->SetChangeCallback([&count]() { ++count; });
|
||||
tc.client->SetChangeCallback(nullptr);
|
||||
|
||||
REQUIRE(tc.client->Test_InsertNode(
|
||||
MakeNode(100820, "cb-clear-node", "Audio/Sink")).ok());
|
||||
REQUIRE(count.load() == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("sidebar tab order is METERS MIXER PRESETS RULES") {
|
||||
auto tc = TestClient::Create();
|
||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||
ensureApp();
|
||||
|
||||
GraphEditorWidget widget(tc.client.get());
|
||||
auto *sidebar = widget.findChild<QTabWidget *>();
|
||||
REQUIRE(sidebar != nullptr);
|
||||
REQUIRE(sidebar->count() >= 4);
|
||||
REQUIRE(sidebar->tabText(0) == "METERS");
|
||||
REQUIRE(sidebar->tabText(1) == "MIXER");
|
||||
REQUIRE(sidebar->tabText(2) == "PRESETS");
|
||||
REQUIRE(sidebar->tabText(3) == "RULES");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
#include <catch2/catch_approx.hpp>
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
namespace {
|
||||
|
|
@ -792,3 +793,169 @@ TEST_CASE("policy mode does not override user defaults") {
|
|||
REQUIRE(defaults2.value.configured_sink_name == defaults.value.configured_sink_name);
|
||||
REQUIRE(defaults2.value.configured_source_name == defaults.value.configured_source_name);
|
||||
}
|
||||
|
||||
TEST_CASE("Test_SetNodeVolume sets and retrieves volume state") {
|
||||
auto result = warppipe::Client::Create(DefaultOptions());
|
||||
REQUIRE(result.ok());
|
||||
auto &client = result.value;
|
||||
|
||||
warppipe::NodeInfo node;
|
||||
node.id = warppipe::NodeId{900};
|
||||
node.name = "vol-sink";
|
||||
node.media_class = "Audio/Sink";
|
||||
REQUIRE(client->Test_InsertNode(node).ok());
|
||||
|
||||
auto vol = client->Test_GetNodeVolume(warppipe::NodeId{900});
|
||||
REQUIRE(vol.ok());
|
||||
REQUIRE(vol.value.volume == Catch::Approx(1.0f));
|
||||
REQUIRE(vol.value.mute == false);
|
||||
|
||||
REQUIRE(client->Test_SetNodeVolume(warppipe::NodeId{900}, 0.5f, false).ok());
|
||||
vol = client->Test_GetNodeVolume(warppipe::NodeId{900});
|
||||
REQUIRE(vol.ok());
|
||||
REQUIRE(vol.value.volume == Catch::Approx(0.5f));
|
||||
REQUIRE(vol.value.mute == false);
|
||||
|
||||
REQUIRE(client->Test_SetNodeVolume(warppipe::NodeId{900}, 0.75f, true).ok());
|
||||
vol = client->Test_GetNodeVolume(warppipe::NodeId{900});
|
||||
REQUIRE(vol.ok());
|
||||
REQUIRE(vol.value.volume == Catch::Approx(0.75f));
|
||||
REQUIRE(vol.value.mute == true);
|
||||
}
|
||||
|
||||
TEST_CASE("Test_SetNodeVolume clamps volume") {
|
||||
auto result = warppipe::Client::Create(DefaultOptions());
|
||||
REQUIRE(result.ok());
|
||||
auto &client = result.value;
|
||||
|
||||
warppipe::NodeInfo node;
|
||||
node.id = warppipe::NodeId{901};
|
||||
node.name = "vol-clamp";
|
||||
node.media_class = "Audio/Sink";
|
||||
REQUIRE(client->Test_InsertNode(node).ok());
|
||||
|
||||
REQUIRE(client->Test_SetNodeVolume(warppipe::NodeId{901}, 2.0f, false).ok());
|
||||
auto vol = client->Test_GetNodeVolume(warppipe::NodeId{901});
|
||||
REQUIRE(vol.ok());
|
||||
REQUIRE(vol.value.volume == Catch::Approx(1.5f));
|
||||
|
||||
REQUIRE(client->Test_SetNodeVolume(warppipe::NodeId{901}, -1.0f, false).ok());
|
||||
vol = client->Test_GetNodeVolume(warppipe::NodeId{901});
|
||||
REQUIRE(vol.ok());
|
||||
REQUIRE(vol.value.volume == Catch::Approx(0.0f));
|
||||
}
|
||||
|
||||
TEST_CASE("Test_SetNodeVolume fails for nonexistent node") {
|
||||
auto result = warppipe::Client::Create(DefaultOptions());
|
||||
REQUIRE(result.ok());
|
||||
|
||||
auto status = result.value->Test_SetNodeVolume(warppipe::NodeId{999}, 0.5f, false);
|
||||
REQUIRE_FALSE(status.ok());
|
||||
REQUIRE(status.code == warppipe::StatusCode::kNotFound);
|
||||
}
|
||||
|
||||
TEST_CASE("EnsureNodeMeter and NodeMeterPeak round-trip") {
|
||||
auto result = warppipe::Client::Create(DefaultOptions());
|
||||
REQUIRE(result.ok());
|
||||
auto &client = result.value;
|
||||
|
||||
warppipe::NodeInfo node;
|
||||
node.id = warppipe::NodeId{950};
|
||||
node.name = "meter-test";
|
||||
node.media_class = "Audio/Sink";
|
||||
REQUIRE(client->Test_InsertNode(node).ok());
|
||||
|
||||
REQUIRE(client->EnsureNodeMeter(warppipe::NodeId{950}).ok());
|
||||
|
||||
auto peak = client->NodeMeterPeak(warppipe::NodeId{950});
|
||||
REQUIRE(peak.ok());
|
||||
REQUIRE(peak.value.peak_left == Catch::Approx(0.0f));
|
||||
REQUIRE(peak.value.peak_right == Catch::Approx(0.0f));
|
||||
}
|
||||
|
||||
TEST_CASE("Test_SetNodeMeterPeak updates peaks") {
|
||||
auto result = warppipe::Client::Create(DefaultOptions());
|
||||
REQUIRE(result.ok());
|
||||
auto &client = result.value;
|
||||
|
||||
warppipe::NodeInfo node;
|
||||
node.id = warppipe::NodeId{951};
|
||||
node.name = "meter-set";
|
||||
node.media_class = "Audio/Sink";
|
||||
REQUIRE(client->Test_InsertNode(node).ok());
|
||||
|
||||
REQUIRE(client->Test_SetNodeMeterPeak(warppipe::NodeId{951}, 0.6f, 0.8f).ok());
|
||||
|
||||
auto peak = client->NodeMeterPeak(warppipe::NodeId{951});
|
||||
REQUIRE(peak.ok());
|
||||
REQUIRE(peak.value.peak_left == Catch::Approx(0.6f));
|
||||
REQUIRE(peak.value.peak_right == Catch::Approx(0.8f));
|
||||
}
|
||||
|
||||
TEST_CASE("DisableNodeMeter removes metering") {
|
||||
auto result = warppipe::Client::Create(DefaultOptions());
|
||||
REQUIRE(result.ok());
|
||||
auto &client = result.value;
|
||||
|
||||
warppipe::NodeInfo node;
|
||||
node.id = warppipe::NodeId{952};
|
||||
node.name = "meter-disable";
|
||||
node.media_class = "Audio/Sink";
|
||||
REQUIRE(client->Test_InsertNode(node).ok());
|
||||
REQUIRE(client->EnsureNodeMeter(warppipe::NodeId{952}).ok());
|
||||
REQUIRE(client->DisableNodeMeter(warppipe::NodeId{952}).ok());
|
||||
|
||||
auto peak = client->NodeMeterPeak(warppipe::NodeId{952});
|
||||
REQUIRE_FALSE(peak.ok());
|
||||
REQUIRE(peak.status.code == warppipe::StatusCode::kNotFound);
|
||||
}
|
||||
|
||||
TEST_CASE("MasterMeterPeak defaults to zero") {
|
||||
auto result = warppipe::Client::Create(DefaultOptions());
|
||||
REQUIRE(result.ok());
|
||||
|
||||
auto peak = result.value->MeterPeak();
|
||||
REQUIRE(peak.ok());
|
||||
REQUIRE(peak.value.peak_left == Catch::Approx(0.0f));
|
||||
REQUIRE(peak.value.peak_right == Catch::Approx(0.0f));
|
||||
}
|
||||
|
||||
TEST_CASE("Test_SetMasterMeterPeak updates master peaks") {
|
||||
auto result = warppipe::Client::Create(DefaultOptions());
|
||||
REQUIRE(result.ok());
|
||||
|
||||
REQUIRE(result.value->Test_SetMasterMeterPeak(0.9f, 0.7f).ok());
|
||||
|
||||
auto peak = result.value->MeterPeak();
|
||||
REQUIRE(peak.ok());
|
||||
REQUIRE(peak.value.peak_left == Catch::Approx(0.9f));
|
||||
REQUIRE(peak.value.peak_right == Catch::Approx(0.7f));
|
||||
}
|
||||
|
||||
TEST_CASE("Test_SetNodeMeterPeak clamps values") {
|
||||
auto result = warppipe::Client::Create(DefaultOptions());
|
||||
REQUIRE(result.ok());
|
||||
auto &client = result.value;
|
||||
|
||||
warppipe::NodeInfo node;
|
||||
node.id = warppipe::NodeId{953};
|
||||
node.name = "meter-clamp";
|
||||
node.media_class = "Audio/Sink";
|
||||
REQUIRE(client->Test_InsertNode(node).ok());
|
||||
|
||||
REQUIRE(client->Test_SetNodeMeterPeak(warppipe::NodeId{953}, 1.5f, -0.5f).ok());
|
||||
|
||||
auto peak = client->NodeMeterPeak(warppipe::NodeId{953});
|
||||
REQUIRE(peak.ok());
|
||||
REQUIRE(peak.value.peak_left == Catch::Approx(1.0f));
|
||||
REQUIRE(peak.value.peak_right == Catch::Approx(0.0f));
|
||||
}
|
||||
|
||||
TEST_CASE("EnsureNodeMeter fails for nonexistent node") {
|
||||
auto result = warppipe::Client::Create(DefaultOptions());
|
||||
REQUIRE(result.ok());
|
||||
|
||||
auto status = result.value->EnsureNodeMeter(warppipe::NodeId{999});
|
||||
REQUIRE_FALSE(status.ok());
|
||||
REQUIRE(status.code == warppipe::StatusCode::kNotFound);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue