This commit is contained in:
Joey Yakimowich-Payne 2026-01-30 10:40:52 -07:00
commit ecec82c70e
10 changed files with 809 additions and 21 deletions

View file

@ -84,6 +84,7 @@ if(WARPPIPE_BUILD_GUI)
gui/GraphEditorWidget.cpp
gui/PresetManager.cpp
gui/VolumeWidgets.cpp
gui/AudioLevelMeter.cpp
)
target_link_libraries(warppipe-gui PRIVATE
@ -100,6 +101,7 @@ if(WARPPIPE_BUILD_GUI)
gui/GraphEditorWidget.cpp
gui/PresetManager.cpp
gui/VolumeWidgets.cpp
gui/AudioLevelMeter.cpp
)
target_compile_definitions(warppipe-gui-tests PRIVATE WARPPIPE_TESTING)

View file

@ -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] Include volume/mute states in preset save/load (`volumes` array in JSON)
- [x] Add tests for volume state tracking, signal emission, widget sync, preset round-trip, cleanup on deletion
- [ ] Milestone 8e - Audio Level Meters (requires core API: `MeterPeak()`, `NodeMeterPeak()`, `EnsureNodeMeter()`)
- [ ] Implement `AudioLevelMeter : QWidget`
- [ ] Custom `paintEvent`: vertical bar from bottom, background `(24,24,28)`
- [ ] 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
- [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
- [ ] 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)`

90
gui/AudioLevelMeter.cpp Normal file
View 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
View 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;
};

View file

@ -1,3 +1,4 @@
#include "AudioLevelMeter.h"
#include "GraphEditorWidget.h"
#include "PresetManager.h"
#include "VolumeWidgets.h"
@ -217,7 +218,55 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
presetsLayout->addWidget(loadPresetBtn);
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->setWidgetResizable(true);
@ -234,6 +283,7 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
mixerLayout->addStretch();
m_mixerScroll->setWidget(m_mixerContainer);
m_sidebar->addTab(m_mixerScroll, QStringLiteral("MIXER"));
m_sidebar->addTab(presetsTab, QStringLiteral("PRESETS"));
m_splitter = new QSplitter(Qt::Horizontal);
m_splitter->addWidget(m_view);
@ -354,11 +404,14 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
[this](QtNodes::NodeId nodeId) {
wireVolumeWidget(nodeId);
rebuildMixerStrips();
rebuildNodeMeters();
});
connect(m_model, &QtNodes::AbstractGraphModel::nodeDeleted, this,
[this](QtNodes::NodeId nodeId) {
m_mixerStrips.erase(nodeId);
m_nodeMeters.erase(nodeId);
rebuildMixerStrips();
rebuildNodeMeters();
});
m_saveTimer = new QTimer(this);
@ -386,6 +439,12 @@ GraphEditorWidget::GraphEditorWidget(warppipe::Client *client,
connect(m_refreshTimer, &QTimer::timeout, this,
&GraphEditorWidget::onRefreshTimer);
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() {
@ -1212,3 +1271,105 @@ void GraphEditorWidget::rebuildMixerStrips() {
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});
}
}
}

View file

@ -17,6 +17,7 @@ class BasicGraphicsScene;
class GraphicsView;
} // namespace QtNodes
class AudioLevelMeter;
class WarpGraphModel;
class NodeVolumeWidget;
class QLabel;
@ -69,6 +70,8 @@ private:
void loadPreset();
void wireVolumeWidget(QtNodes::NodeId nodeId);
void rebuildMixerStrips();
void updateMeters();
void rebuildNodeMeters();
struct PendingPasteLink {
std::string outNodeName;
@ -95,4 +98,16 @@ private:
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;
};

View file

@ -142,6 +142,11 @@ struct VolumeState {
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;
@ -174,6 +179,11 @@ class Client {
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,
@ -203,6 +213,8 @@ class Client {
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:

View file

@ -1,10 +1,13 @@
#include <algorithm>
#include <atomic>
#include <cerrno>
#include <cmath>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <mutex>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <pipewire/keys.h>
@ -241,6 +244,52 @@ static const pw_stream_events kStreamEvents = {
.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
Status Status::Ok() {
@ -281,6 +330,13 @@ struct Client::Impl {
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;
std::unordered_map<uint32_t, RouteRule> route_rules;
std::vector<PendingAutoLink> pending_auto_links;
@ -307,6 +363,9 @@ struct Client::Impl {
void ProcessPendingAutoLinks();
void CreateAutoLinkAsync(uint32_t output_port, uint32_t input_port);
void AutoSave();
void SetupMasterMeter();
void TeardownMasterMeter();
void TeardownAllLiveMeters();
static void RegistryGlobal(void* data,
uint32_t id,
@ -730,10 +789,14 @@ Status Client::Impl::ConnectLocked() {
if (!sync_status.ok()) {
return sync_status;
}
SetupMasterMeter();
return Status::Ok();
}
void Client::Impl::DisconnectLocked() {
TeardownMasterMeter();
TeardownAllLiveMeters();
std::unordered_map<uint32_t, std::unique_ptr<LinkProxy>> links;
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,
const char* key, const char* type,
const char* value) {
@ -1278,6 +1409,140 @@ Result<VolumeState> Client::GetNodeVolume(NodeId node) const {
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) {
Status status = impl_->EnsureConnected();
if (!status.ok()) {
@ -1805,6 +2070,38 @@ Result<VolumeState> Client::Test_GetNodeVolume(NodeId node) const {
}
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
} // namespace warppipe

View file

@ -1,5 +1,6 @@
#include <warppipe/warppipe.hpp>
#include "../../gui/AudioLevelMeter.h"
#include "../../gui/GraphEditorWidget.h"
#include "../../gui/PresetManager.h"
#include "../../gui/VolumeWidgets.h"
@ -12,6 +13,7 @@
#include <QJsonDocument>
#include <QJsonObject>
#include <QStandardPaths>
#include <QTabWidget>
#include <catch2/catch_test_macros.hpp>
#include <catch2/catch_approx.hpp>
@ -1151,6 +1153,81 @@ TEST_CASE("preset saves and loads volume state") {
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; }

View file

@ -853,3 +853,109 @@ TEST_CASE("Test_SetNodeVolume fails for nonexistent node") {
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);
}