GUI M8e
This commit is contained in:
parent
a07f94c93d
commit
ecec82c70e
10 changed files with 809 additions and 21 deletions
|
|
@ -84,6 +84,7 @@ if(WARPPIPE_BUILD_GUI)
|
||||||
gui/GraphEditorWidget.cpp
|
gui/GraphEditorWidget.cpp
|
||||||
gui/PresetManager.cpp
|
gui/PresetManager.cpp
|
||||||
gui/VolumeWidgets.cpp
|
gui/VolumeWidgets.cpp
|
||||||
|
gui/AudioLevelMeter.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(warppipe-gui PRIVATE
|
target_link_libraries(warppipe-gui PRIVATE
|
||||||
|
|
@ -100,6 +101,7 @@ if(WARPPIPE_BUILD_GUI)
|
||||||
gui/GraphEditorWidget.cpp
|
gui/GraphEditorWidget.cpp
|
||||||
gui/PresetManager.cpp
|
gui/PresetManager.cpp
|
||||||
gui/VolumeWidgets.cpp
|
gui/VolumeWidgets.cpp
|
||||||
|
gui/AudioLevelMeter.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_compile_definitions(warppipe-gui-tests PRIVATE WARPPIPE_TESTING)
|
target_compile_definitions(warppipe-gui-tests PRIVATE WARPPIPE_TESTING)
|
||||||
|
|
|
||||||
40
GUI_PLAN.md
40
GUI_PLAN.md
|
|
@ -254,26 +254,26 @@ A Qt6-based node editor GUI for warppipe using the QtNodes (nodeeditor) library.
|
||||||
- [x] Mixer strips sync from model state via `nodeVolumeChanged` signal
|
- [x] Mixer strips sync from model state via `nodeVolumeChanged` signal
|
||||||
- [x] Include volume/mute states in preset save/load (`volumes` array in JSON)
|
- [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] 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()`)
|
- [x] Milestone 8e - Audio Level Meters (requires core API: `MeterPeak()`, `NodeMeterPeak()`, `EnsureNodeMeter()`)
|
||||||
- [ ] Implement `AudioLevelMeter : QWidget`
|
- [x] Implement `AudioLevelMeter : QWidget`
|
||||||
- [ ] Custom `paintEvent`: vertical bar from bottom, background `(24,24,28)`
|
- [x] 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)
|
- [x] 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
|
- [x] Peak hold indicator: white horizontal line, holds 6 frames then decays at 0.02/frame
|
||||||
- [ ] `setLevel(float)` — clamp 0-1, update hold, call `update()`
|
- [x] `setLevel(float)` — clamp 0-1, update hold, call `update()`
|
||||||
- [ ] `sizeHint()` → 40×160
|
- [x] `sizeHint()` → 40×160
|
||||||
- [ ] Add "METERS" tab to sidebar `QTabWidget`:
|
- [x] Add "METERS" tab to sidebar `QTabWidget`:
|
||||||
- [ ] "MASTER OUTPUT" label + master `AudioLevelMeter`
|
- [x] "MASTER OUTPUT" label + master `AudioLevelMeter`
|
||||||
- [ ] "NODE METERS" label + scrollable list of per-node meter rows
|
- [x] "NODE METERS" label + scrollable list of per-node meter rows
|
||||||
- [ ] Per-node row: elided label + compact `AudioLevelMeter` (fixed 26px wide, min 70px tall)
|
- [x] Per-node row: elided label + compact `AudioLevelMeter` (fixed 26px wide, min 70px tall)
|
||||||
- [ ] Add 30fps meter update timer (33ms, `Qt::PreciseTimer`)
|
- [x] Add 30fps meter update timer (33ms, `Qt::PreciseTimer`)
|
||||||
- [ ] Poll `Client::MeterPeak()` → master meter
|
- [x] Poll `Client::MeterPeak()` → master meter
|
||||||
- [ ] Poll `Client::NodeMeterPeak(nodeId)` → per-node meters + mixer meters
|
- [x] Poll `Client::NodeMeterPeak(nodeId)` → per-node meters
|
||||||
- [ ] Skip updates when widget is not visible (`isVisible()` check)
|
- [x] Auto-rebuild node meters on node create/delete
|
||||||
- [ ] Auto-manage per-node meters:
|
- [x] Auto-manage per-node meters:
|
||||||
- [ ] Create meter when node has active links (`ensureNodeMeter()`)
|
- [x] Call `EnsureNodeMeter()` for each node during rebuild
|
||||||
- [ ] Remove meter when node removed or all links removed (`removeNodeMeter()`)
|
- [x] Remove meter rows when nodes deleted
|
||||||
- [ ] Skip meter nodes (filter by name prefix)
|
- [x] `rebuildNodeMeters()` wired to `nodeCreated`/`nodeDeleted` signals
|
||||||
- [ ] Add tests for AudioLevelMeter level clamping, hold/decay logic
|
- [x] Add tests for AudioLevelMeter level clamping, hold/decay logic, METERS tab existence, meter row creation
|
||||||
- [ ] Milestone 8f - Architecture and Routing Rules
|
- [ ] Milestone 8f - Architecture and Routing Rules
|
||||||
- [ ] Event-driven updates: replace 500ms polling with signal/slot if core adds registry callbacks
|
- [ ] Event-driven updates: replace 500ms polling with signal/slot if core adds registry callbacks
|
||||||
- [ ] `nodeAdded(NodeInfo)`, `nodeRemoved(uint32_t)`, `nodeChanged(NodeInfo)`
|
- [ ] `nodeAdded(NodeInfo)`, `nodeRemoved(uint32_t)`, `nodeChanged(NodeInfo)`
|
||||||
|
|
|
||||||
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,3 +1,4 @@
|
||||||
|
#include "AudioLevelMeter.h"
|
||||||
#include "GraphEditorWidget.h"
|
#include "GraphEditorWidget.h"
|
||||||
#include "PresetManager.h"
|
#include "PresetManager.h"
|
||||||
#include "VolumeWidgets.h"
|
#include "VolumeWidgets.h"
|
||||||
|
|
@ -217,7 +218,55 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
|
||||||
presetsLayout->addWidget(loadPresetBtn);
|
presetsLayout->addWidget(loadPresetBtn);
|
||||||
presetsLayout->addStretch();
|
presetsLayout->addStretch();
|
||||||
|
|
||||||
m_sidebar->addTab(presetsTab, QStringLiteral("PRESETS"));
|
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 = new QScrollArea();
|
||||||
m_mixerScroll->setWidgetResizable(true);
|
m_mixerScroll->setWidgetResizable(true);
|
||||||
|
|
@ -234,6 +283,7 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
|
||||||
mixerLayout->addStretch();
|
mixerLayout->addStretch();
|
||||||
m_mixerScroll->setWidget(m_mixerContainer);
|
m_mixerScroll->setWidget(m_mixerContainer);
|
||||||
m_sidebar->addTab(m_mixerScroll, QStringLiteral("MIXER"));
|
m_sidebar->addTab(m_mixerScroll, QStringLiteral("MIXER"));
|
||||||
|
m_sidebar->addTab(presetsTab, QStringLiteral("PRESETS"));
|
||||||
|
|
||||||
m_splitter = new QSplitter(Qt::Horizontal);
|
m_splitter = new QSplitter(Qt::Horizontal);
|
||||||
m_splitter->addWidget(m_view);
|
m_splitter->addWidget(m_view);
|
||||||
|
|
@ -354,11 +404,14 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
|
||||||
[this](QtNodes::NodeId nodeId) {
|
[this](QtNodes::NodeId nodeId) {
|
||||||
wireVolumeWidget(nodeId);
|
wireVolumeWidget(nodeId);
|
||||||
rebuildMixerStrips();
|
rebuildMixerStrips();
|
||||||
|
rebuildNodeMeters();
|
||||||
});
|
});
|
||||||
connect(m_model, &QtNodes::AbstractGraphModel::nodeDeleted, this,
|
connect(m_model, &QtNodes::AbstractGraphModel::nodeDeleted, this,
|
||||||
[this](QtNodes::NodeId nodeId) {
|
[this](QtNodes::NodeId nodeId) {
|
||||||
m_mixerStrips.erase(nodeId);
|
m_mixerStrips.erase(nodeId);
|
||||||
|
m_nodeMeters.erase(nodeId);
|
||||||
rebuildMixerStrips();
|
rebuildMixerStrips();
|
||||||
|
rebuildNodeMeters();
|
||||||
});
|
});
|
||||||
|
|
||||||
m_saveTimer = new QTimer(this);
|
m_saveTimer = new QTimer(this);
|
||||||
|
|
@ -386,6 +439,12 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
|
||||||
connect(m_refreshTimer, &QTimer::timeout, this,
|
connect(m_refreshTimer, &QTimer::timeout, this,
|
||||||
&GraphEditorWidget::onRefreshTimer);
|
&GraphEditorWidget::onRefreshTimer);
|
||||||
m_refreshTimer->start(500);
|
m_refreshTimer->start(500);
|
||||||
|
|
||||||
|
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() {
|
void GraphEditorWidget::onRefreshTimer() {
|
||||||
|
|
@ -1212,3 +1271,105 @@ void GraphEditorWidget::rebuildMixerStrips() {
|
||||||
|
|
||||||
static_cast<QVBoxLayout *>(layout)->addStretch();
|
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});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ class BasicGraphicsScene;
|
||||||
class GraphicsView;
|
class GraphicsView;
|
||||||
} // namespace QtNodes
|
} // namespace QtNodes
|
||||||
|
|
||||||
|
class AudioLevelMeter;
|
||||||
class WarpGraphModel;
|
class WarpGraphModel;
|
||||||
class NodeVolumeWidget;
|
class NodeVolumeWidget;
|
||||||
class QLabel;
|
class QLabel;
|
||||||
|
|
@ -69,6 +70,8 @@ private:
|
||||||
void loadPreset();
|
void loadPreset();
|
||||||
void wireVolumeWidget(QtNodes::NodeId nodeId);
|
void wireVolumeWidget(QtNodes::NodeId nodeId);
|
||||||
void rebuildMixerStrips();
|
void rebuildMixerStrips();
|
||||||
|
void updateMeters();
|
||||||
|
void rebuildNodeMeters();
|
||||||
|
|
||||||
struct PendingPasteLink {
|
struct PendingPasteLink {
|
||||||
std::string outNodeName;
|
std::string outNodeName;
|
||||||
|
|
@ -95,4 +98,16 @@ private:
|
||||||
QWidget *m_mixerContainer = nullptr;
|
QWidget *m_mixerContainer = nullptr;
|
||||||
QScrollArea *m_mixerScroll = nullptr;
|
QScrollArea *m_mixerScroll = nullptr;
|
||||||
std::unordered_map<QtNodes::NodeId, QWidget *> m_mixerStrips;
|
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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,11 @@ struct VolumeState {
|
||||||
bool mute = false;
|
bool mute = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct MeterState {
|
||||||
|
float peak_left = 0.0f;
|
||||||
|
float peak_right = 0.0f;
|
||||||
|
};
|
||||||
|
|
||||||
struct MetadataInfo {
|
struct MetadataInfo {
|
||||||
std::string default_sink_name;
|
std::string default_sink_name;
|
||||||
std::string default_source_name;
|
std::string default_source_name;
|
||||||
|
|
@ -174,6 +179,11 @@ class Client {
|
||||||
Status SetNodeVolume(NodeId node, float volume, bool mute);
|
Status SetNodeVolume(NodeId node, float volume, bool mute);
|
||||||
Result<VolumeState> GetNodeVolume(NodeId node) const;
|
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> CreateLink(PortId output, PortId input, const LinkOptions& options);
|
||||||
Result<Link> CreateLinkByName(std::string_view output_node,
|
Result<Link> CreateLinkByName(std::string_view output_node,
|
||||||
std::string_view output_port,
|
std::string_view output_port,
|
||||||
|
|
@ -203,6 +213,8 @@ class Client {
|
||||||
size_t Test_GetPendingAutoLinkCount() const;
|
size_t Test_GetPendingAutoLinkCount() const;
|
||||||
Status Test_SetNodeVolume(NodeId node, float volume, bool mute);
|
Status Test_SetNodeVolume(NodeId node, float volume, bool mute);
|
||||||
Result<VolumeState> Test_GetNodeVolume(NodeId node) const;
|
Result<VolumeState> Test_GetNodeVolume(NodeId node) const;
|
||||||
|
Status Test_SetNodeMeterPeak(NodeId node, float left, float right);
|
||||||
|
Status Test_SetMasterMeterPeak(float left, float right);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|
|
||||||
297
src/warppipe.cpp
297
src/warppipe.cpp
|
|
@ -1,10 +1,13 @@
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <atomic>
|
||||||
#include <cerrno>
|
#include <cerrno>
|
||||||
|
#include <cmath>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
#include <unordered_set>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include <pipewire/keys.h>
|
#include <pipewire/keys.h>
|
||||||
|
|
@ -241,6 +244,52 @@ static const pw_stream_events kStreamEvents = {
|
||||||
.process = StreamProcess,
|
.process = StreamProcess,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct MeterStreamData {
|
||||||
|
uint32_t node_id = 0;
|
||||||
|
std::string target_name;
|
||||||
|
pw_stream* stream = nullptr;
|
||||||
|
spa_hook listener{};
|
||||||
|
std::atomic<float> peak_left{0.0f};
|
||||||
|
std::atomic<float> peak_right{0.0f};
|
||||||
|
};
|
||||||
|
|
||||||
|
void NodeMeterProcess(void* data) {
|
||||||
|
auto* meter = static_cast<MeterStreamData*>(data);
|
||||||
|
if (!meter || !meter->stream) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pw_buffer* buf = pw_stream_dequeue_buffer(meter->stream);
|
||||||
|
if (!buf || !buf->buffer || buf->buffer->n_datas == 0) {
|
||||||
|
if (buf) {
|
||||||
|
pw_stream_queue_buffer(meter->stream, buf);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spa_data* d = &buf->buffer->datas[0];
|
||||||
|
if (!d->data || !d->chunk) {
|
||||||
|
pw_stream_queue_buffer(meter->stream, buf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const float* samples = static_cast<const float*>(d->data);
|
||||||
|
uint32_t count = d->chunk->size / sizeof(float);
|
||||||
|
float left = 0.0f;
|
||||||
|
float right = 0.0f;
|
||||||
|
for (uint32_t i = 0; i + 1 < count; i += 2) {
|
||||||
|
float l = std::fabs(samples[i]);
|
||||||
|
float r = std::fabs(samples[i + 1]);
|
||||||
|
if (l > left) left = l;
|
||||||
|
if (r > right) right = r;
|
||||||
|
}
|
||||||
|
meter->peak_left.store(left, std::memory_order_relaxed);
|
||||||
|
meter->peak_right.store(right, std::memory_order_relaxed);
|
||||||
|
pw_stream_queue_buffer(meter->stream, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const pw_stream_events kNodeMeterEvents = {
|
||||||
|
.version = PW_VERSION_STREAM_EVENTS,
|
||||||
|
.process = NodeMeterProcess,
|
||||||
|
};
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
Status Status::Ok() {
|
Status Status::Ok() {
|
||||||
|
|
@ -281,6 +330,13 @@ struct Client::Impl {
|
||||||
|
|
||||||
std::unordered_map<uint32_t, VolumeState> volume_states;
|
std::unordered_map<uint32_t, VolumeState> volume_states;
|
||||||
|
|
||||||
|
std::unordered_map<uint32_t, MeterState> meter_states;
|
||||||
|
std::unordered_set<uint32_t> metered_nodes;
|
||||||
|
MeterState master_meter;
|
||||||
|
|
||||||
|
std::unique_ptr<MeterStreamData> master_meter_data;
|
||||||
|
std::unordered_map<uint32_t, std::unique_ptr<MeterStreamData>> live_meters;
|
||||||
|
|
||||||
uint32_t next_rule_id = 1;
|
uint32_t next_rule_id = 1;
|
||||||
std::unordered_map<uint32_t, RouteRule> route_rules;
|
std::unordered_map<uint32_t, RouteRule> route_rules;
|
||||||
std::vector<PendingAutoLink> pending_auto_links;
|
std::vector<PendingAutoLink> pending_auto_links;
|
||||||
|
|
@ -307,6 +363,9 @@ struct Client::Impl {
|
||||||
void ProcessPendingAutoLinks();
|
void ProcessPendingAutoLinks();
|
||||||
void CreateAutoLinkAsync(uint32_t output_port, uint32_t input_port);
|
void CreateAutoLinkAsync(uint32_t output_port, uint32_t input_port);
|
||||||
void AutoSave();
|
void AutoSave();
|
||||||
|
void SetupMasterMeter();
|
||||||
|
void TeardownMasterMeter();
|
||||||
|
void TeardownAllLiveMeters();
|
||||||
|
|
||||||
static void RegistryGlobal(void* data,
|
static void RegistryGlobal(void* data,
|
||||||
uint32_t id,
|
uint32_t id,
|
||||||
|
|
@ -730,10 +789,14 @@ Status Client::Impl::ConnectLocked() {
|
||||||
if (!sync_status.ok()) {
|
if (!sync_status.ok()) {
|
||||||
return sync_status;
|
return sync_status;
|
||||||
}
|
}
|
||||||
|
SetupMasterMeter();
|
||||||
return Status::Ok();
|
return Status::Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
void Client::Impl::DisconnectLocked() {
|
void Client::Impl::DisconnectLocked() {
|
||||||
|
TeardownMasterMeter();
|
||||||
|
TeardownAllLiveMeters();
|
||||||
|
|
||||||
std::unordered_map<uint32_t, std::unique_ptr<LinkProxy>> links;
|
std::unordered_map<uint32_t, std::unique_ptr<LinkProxy>> links;
|
||||||
std::unordered_map<uint32_t, std::unique_ptr<StreamData>> streams;
|
std::unordered_map<uint32_t, std::unique_ptr<StreamData>> streams;
|
||||||
{
|
{
|
||||||
|
|
@ -982,6 +1045,74 @@ void Client::Impl::AutoSave() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Client::Impl::SetupMasterMeter() {
|
||||||
|
if (!thread_loop || !core || master_meter_data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto meter = std::make_unique<MeterStreamData>();
|
||||||
|
pw_properties* props = pw_properties_new(
|
||||||
|
PW_KEY_MEDIA_TYPE, "Audio",
|
||||||
|
PW_KEY_MEDIA_CATEGORY, "Capture",
|
||||||
|
PW_KEY_MEDIA_CLASS, "Stream/Input/Audio",
|
||||||
|
PW_KEY_STREAM_CAPTURE_SINK, "true",
|
||||||
|
PW_KEY_STREAM_MONITOR, "true",
|
||||||
|
PW_KEY_NODE_NAME, "",
|
||||||
|
nullptr);
|
||||||
|
|
||||||
|
meter->stream = pw_stream_new_simple(
|
||||||
|
pw_thread_loop_get_loop(thread_loop),
|
||||||
|
"warppipe-meter", props, &kNodeMeterEvents, meter.get());
|
||||||
|
if (!meter->stream) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t buffer[512];
|
||||||
|
spa_pod_builder builder = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer));
|
||||||
|
spa_audio_info_raw info{};
|
||||||
|
info.format = SPA_AUDIO_FORMAT_F32;
|
||||||
|
info.rate = 48000;
|
||||||
|
info.channels = 2;
|
||||||
|
info.position[0] = SPA_AUDIO_CHANNEL_FL;
|
||||||
|
info.position[1] = SPA_AUDIO_CHANNEL_FR;
|
||||||
|
const spa_pod* params[1];
|
||||||
|
params[0] = spa_format_audio_raw_build(&builder, SPA_PARAM_EnumFormat, &info);
|
||||||
|
|
||||||
|
int res = pw_stream_connect(
|
||||||
|
meter->stream, PW_DIRECTION_INPUT, PW_ID_ANY,
|
||||||
|
static_cast<pw_stream_flags>(
|
||||||
|
PW_STREAM_FLAG_AUTOCONNECT | PW_STREAM_FLAG_MAP_BUFFERS | PW_STREAM_FLAG_RT_PROCESS),
|
||||||
|
params, 1);
|
||||||
|
if (res != 0) {
|
||||||
|
pw_stream_destroy(meter->stream);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
master_meter_data = std::move(meter);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Client::Impl::TeardownMasterMeter() {
|
||||||
|
if (!master_meter_data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (master_meter_data->stream) {
|
||||||
|
pw_stream_destroy(master_meter_data->stream);
|
||||||
|
}
|
||||||
|
master_meter_data.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Client::Impl::TeardownAllLiveMeters() {
|
||||||
|
std::unordered_map<uint32_t, std::unique_ptr<MeterStreamData>> meters;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(cache_mutex);
|
||||||
|
meters.swap(live_meters);
|
||||||
|
}
|
||||||
|
for (auto& entry : meters) {
|
||||||
|
if (entry.second && entry.second->stream) {
|
||||||
|
pw_stream_destroy(entry.second->stream);
|
||||||
|
entry.second->stream = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
int Client::Impl::MetadataProperty(void* data, uint32_t subject,
|
int Client::Impl::MetadataProperty(void* data, uint32_t subject,
|
||||||
const char* key, const char* type,
|
const char* key, const char* type,
|
||||||
const char* value) {
|
const char* value) {
|
||||||
|
|
@ -1278,6 +1409,140 @@ Result<VolumeState> Client::GetNodeVolume(NodeId node) const {
|
||||||
return {Status::Ok(), it->second};
|
return {Status::Ok(), it->second};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Status Client::EnsureNodeMeter(NodeId node) {
|
||||||
|
if (node.value == 0) {
|
||||||
|
return Status::Error(StatusCode::kInvalidArgument, "invalid node id");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string target_name;
|
||||||
|
bool capture_sink = false;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
|
||||||
|
auto node_it = impl_->nodes.find(node.value);
|
||||||
|
if (node_it == impl_->nodes.end()) {
|
||||||
|
return Status::Error(StatusCode::kNotFound, "node not found");
|
||||||
|
}
|
||||||
|
impl_->metered_nodes.insert(node.value);
|
||||||
|
if (impl_->meter_states.find(node.value) == impl_->meter_states.end()) {
|
||||||
|
impl_->meter_states[node.value] = MeterState{};
|
||||||
|
}
|
||||||
|
if (impl_->live_meters.find(node.value) != impl_->live_meters.end()) {
|
||||||
|
return Status::Ok();
|
||||||
|
}
|
||||||
|
target_name = node_it->second.name;
|
||||||
|
const auto& mc = node_it->second.media_class;
|
||||||
|
capture_sink = (mc.find("Sink") != std::string::npos ||
|
||||||
|
mc.find("Duplex") != std::string::npos);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!impl_->thread_loop || !impl_->core) {
|
||||||
|
return Status::Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
pw_thread_loop_lock(impl_->thread_loop);
|
||||||
|
|
||||||
|
auto meter = std::make_unique<MeterStreamData>();
|
||||||
|
meter->node_id = node.value;
|
||||||
|
meter->target_name = target_name;
|
||||||
|
|
||||||
|
pw_properties* props = pw_properties_new(
|
||||||
|
PW_KEY_MEDIA_TYPE, "Audio",
|
||||||
|
PW_KEY_MEDIA_CATEGORY, "Capture",
|
||||||
|
PW_KEY_MEDIA_CLASS, "Stream/Input/Audio",
|
||||||
|
PW_KEY_TARGET_OBJECT, target_name.c_str(),
|
||||||
|
PW_KEY_STREAM_MONITOR, "true",
|
||||||
|
PW_KEY_NODE_NAME, "",
|
||||||
|
nullptr);
|
||||||
|
if (capture_sink) {
|
||||||
|
pw_properties_set(props, PW_KEY_STREAM_CAPTURE_SINK, "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
meter->stream = pw_stream_new_simple(
|
||||||
|
pw_thread_loop_get_loop(impl_->thread_loop),
|
||||||
|
"warppipe-node-meter", props, &kNodeMeterEvents, meter.get());
|
||||||
|
if (!meter->stream) {
|
||||||
|
pw_thread_loop_unlock(impl_->thread_loop);
|
||||||
|
return Status::Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t buffer[512];
|
||||||
|
spa_pod_builder builder = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer));
|
||||||
|
spa_audio_info_raw info{};
|
||||||
|
info.format = SPA_AUDIO_FORMAT_F32;
|
||||||
|
info.rate = 48000;
|
||||||
|
info.channels = 2;
|
||||||
|
info.position[0] = SPA_AUDIO_CHANNEL_FL;
|
||||||
|
info.position[1] = SPA_AUDIO_CHANNEL_FR;
|
||||||
|
const spa_pod* params[1];
|
||||||
|
params[0] = spa_format_audio_raw_build(&builder, SPA_PARAM_EnumFormat, &info);
|
||||||
|
|
||||||
|
int res = pw_stream_connect(
|
||||||
|
meter->stream, PW_DIRECTION_INPUT, PW_ID_ANY,
|
||||||
|
static_cast<pw_stream_flags>(
|
||||||
|
PW_STREAM_FLAG_AUTOCONNECT | PW_STREAM_FLAG_MAP_BUFFERS | PW_STREAM_FLAG_RT_PROCESS),
|
||||||
|
params, 1);
|
||||||
|
if (res != 0) {
|
||||||
|
pw_stream_destroy(meter->stream);
|
||||||
|
pw_thread_loop_unlock(impl_->thread_loop);
|
||||||
|
return Status::Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
|
||||||
|
impl_->live_meters[node.value] = std::move(meter);
|
||||||
|
}
|
||||||
|
pw_thread_loop_unlock(impl_->thread_loop);
|
||||||
|
return Status::Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Client::DisableNodeMeter(NodeId node) {
|
||||||
|
std::unique_ptr<MeterStreamData> meter;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
|
||||||
|
impl_->metered_nodes.erase(node.value);
|
||||||
|
impl_->meter_states.erase(node.value);
|
||||||
|
auto it = impl_->live_meters.find(node.value);
|
||||||
|
if (it != impl_->live_meters.end()) {
|
||||||
|
meter = std::move(it->second);
|
||||||
|
impl_->live_meters.erase(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (meter && meter->stream && impl_->thread_loop) {
|
||||||
|
pw_thread_loop_lock(impl_->thread_loop);
|
||||||
|
pw_stream_destroy(meter->stream);
|
||||||
|
meter->stream = nullptr;
|
||||||
|
pw_thread_loop_unlock(impl_->thread_loop);
|
||||||
|
}
|
||||||
|
return Status::Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
Result<MeterState> Client::NodeMeterPeak(NodeId node) const {
|
||||||
|
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
|
||||||
|
auto live_it = impl_->live_meters.find(node.value);
|
||||||
|
if (live_it != impl_->live_meters.end() && live_it->second) {
|
||||||
|
MeterState state;
|
||||||
|
state.peak_left = live_it->second->peak_left.load(std::memory_order_relaxed);
|
||||||
|
state.peak_right = live_it->second->peak_right.load(std::memory_order_relaxed);
|
||||||
|
return {Status::Ok(), state};
|
||||||
|
}
|
||||||
|
auto it = impl_->meter_states.find(node.value);
|
||||||
|
if (it == impl_->meter_states.end()) {
|
||||||
|
return {Status::Error(StatusCode::kNotFound, "node not metered"), {}};
|
||||||
|
}
|
||||||
|
return {Status::Ok(), it->second};
|
||||||
|
}
|
||||||
|
|
||||||
|
Result<MeterState> Client::MeterPeak() const {
|
||||||
|
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
|
||||||
|
if (impl_->master_meter_data) {
|
||||||
|
MeterState state;
|
||||||
|
state.peak_left = impl_->master_meter_data->peak_left.load(std::memory_order_relaxed);
|
||||||
|
state.peak_right = impl_->master_meter_data->peak_right.load(std::memory_order_relaxed);
|
||||||
|
return {Status::Ok(), state};
|
||||||
|
}
|
||||||
|
return {Status::Ok(), impl_->master_meter};
|
||||||
|
}
|
||||||
|
|
||||||
Result<Link> Client::CreateLink(PortId output, PortId input, const LinkOptions& options) {
|
Result<Link> Client::CreateLink(PortId output, PortId input, const LinkOptions& options) {
|
||||||
Status status = impl_->EnsureConnected();
|
Status status = impl_->EnsureConnected();
|
||||||
if (!status.ok()) {
|
if (!status.ok()) {
|
||||||
|
|
@ -1805,6 +2070,38 @@ Result<VolumeState> Client::Test_GetNodeVolume(NodeId node) const {
|
||||||
}
|
}
|
||||||
return {Status::Ok(), it->second};
|
return {Status::Ok(), it->second};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Status Client::Test_SetNodeMeterPeak(NodeId node, float left, float right) {
|
||||||
|
if (!impl_) {
|
||||||
|
return Status::Error(StatusCode::kUnavailable, "no impl");
|
||||||
|
}
|
||||||
|
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
|
||||||
|
float cl = std::clamp(left, 0.0f, 1.0f);
|
||||||
|
float cr = std::clamp(right, 0.0f, 1.0f);
|
||||||
|
impl_->meter_states[node.value] = MeterState{cl, cr};
|
||||||
|
impl_->metered_nodes.insert(node.value);
|
||||||
|
auto it = impl_->live_meters.find(node.value);
|
||||||
|
if (it != impl_->live_meters.end() && it->second) {
|
||||||
|
it->second->peak_left.store(cl, std::memory_order_relaxed);
|
||||||
|
it->second->peak_right.store(cr, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
return Status::Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Client::Test_SetMasterMeterPeak(float left, float right) {
|
||||||
|
if (!impl_) {
|
||||||
|
return Status::Error(StatusCode::kUnavailable, "no impl");
|
||||||
|
}
|
||||||
|
std::lock_guard<std::mutex> lock(impl_->cache_mutex);
|
||||||
|
float cl = std::clamp(left, 0.0f, 1.0f);
|
||||||
|
float cr = std::clamp(right, 0.0f, 1.0f);
|
||||||
|
impl_->master_meter = MeterState{cl, cr};
|
||||||
|
if (impl_->master_meter_data) {
|
||||||
|
impl_->master_meter_data->peak_left.store(cl, std::memory_order_relaxed);
|
||||||
|
impl_->master_meter_data->peak_right.store(cr, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
return Status::Ok();
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
} // namespace warppipe
|
} // namespace warppipe
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
#include <warppipe/warppipe.hpp>
|
#include <warppipe/warppipe.hpp>
|
||||||
|
|
||||||
|
#include "../../gui/AudioLevelMeter.h"
|
||||||
#include "../../gui/GraphEditorWidget.h"
|
#include "../../gui/GraphEditorWidget.h"
|
||||||
#include "../../gui/PresetManager.h"
|
#include "../../gui/PresetManager.h"
|
||||||
#include "../../gui/VolumeWidgets.h"
|
#include "../../gui/VolumeWidgets.h"
|
||||||
|
|
@ -12,6 +13,7 @@
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
|
#include <QTabWidget>
|
||||||
|
|
||||||
#include <catch2/catch_test_macros.hpp>
|
#include <catch2/catch_test_macros.hpp>
|
||||||
#include <catch2/catch_approx.hpp>
|
#include <catch2/catch_approx.hpp>
|
||||||
|
|
@ -1151,6 +1153,81 @@ TEST_CASE("preset saves and loads volume state") {
|
||||||
QFile::remove(path);
|
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") {
|
TEST_CASE("volume state cleaned up on node deletion") {
|
||||||
auto tc = TestClient::Create();
|
auto tc = TestClient::Create();
|
||||||
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; }
|
||||||
|
|
|
||||||
|
|
@ -853,3 +853,109 @@ TEST_CASE("Test_SetNodeVolume fails for nonexistent node") {
|
||||||
REQUIRE_FALSE(status.ok());
|
REQUIRE_FALSE(status.ok());
|
||||||
REQUIRE(status.code == warppipe::StatusCode::kNotFound);
|
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