From ecec82c70e922b6594431a242bed96ac77243a8b Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Fri, 30 Jan 2026 10:40:52 -0700 Subject: [PATCH] GUI M8e --- CMakeLists.txt | 2 + GUI_PLAN.md | 40 ++--- gui/AudioLevelMeter.cpp | 90 ++++++++++ gui/AudioLevelMeter.h | 28 +++ gui/GraphEditorWidget.cpp | 163 ++++++++++++++++- gui/GraphEditorWidget.h | 15 ++ include/warppipe/warppipe.hpp | 12 ++ src/warppipe.cpp | 297 +++++++++++++++++++++++++++++++ tests/gui/warppipe_gui_tests.cpp | 77 ++++++++ tests/warppipe_tests.cpp | 106 +++++++++++ 10 files changed, 809 insertions(+), 21 deletions(-) create mode 100644 gui/AudioLevelMeter.cpp create mode 100644 gui/AudioLevelMeter.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 2bfc81b..f383700 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/GUI_PLAN.md b/GUI_PLAN.md index e248444..419b159 100644 --- a/GUI_PLAN.md +++ b/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] 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)` diff --git a/gui/AudioLevelMeter.cpp b/gui/AudioLevelMeter.cpp new file mode 100644 index 0000000..c4b9a8e --- /dev/null +++ b/gui/AudioLevelMeter.cpp @@ -0,0 +1,90 @@ +#include "AudioLevelMeter.h" + +#include + +#include + +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(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(greenEnd)); + if (greenH > 0) { + painter.fillRect(r.left(), r.bottom() - greenH + 1, r.width(), greenH, + QColor(76, 175, 80)); + } + + if (barHeight > static_cast(greenEnd)) { + int yellowH = + std::min(barHeight - static_cast(greenEnd), + static_cast(yellowEnd) - static_cast(greenEnd)); + if (yellowH > 0) { + painter.fillRect(r.left(), r.bottom() - static_cast(greenEnd) - yellowH + 1, + r.width(), yellowH, QColor(255, 193, 7)); + } + } + + if (barHeight > static_cast(yellowEnd)) { + int redH = barHeight - static_cast(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(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); + } +} diff --git a/gui/AudioLevelMeter.h b/gui/AudioLevelMeter.h new file mode 100644 index 0000000..b9ef1e1 --- /dev/null +++ b/gui/AudioLevelMeter.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +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; +}; diff --git a/gui/GraphEditorWidget.cpp b/gui/GraphEditorWidget.cpp index 94a4724..bf4cc30 100644 --- a/gui/GraphEditorWidget.cpp +++ b/gui/GraphEditorWidget.cpp @@ -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(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 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 sorted(nodeIds.begin(), nodeIds.end()); + std::sort(sorted.begin(), sorted.end()); + + std::unordered_map 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(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}); + } + } +} diff --git a/gui/GraphEditorWidget.h b/gui/GraphEditorWidget.h index 05dfb7e..bd050b1 100644 --- a/gui/GraphEditorWidget.h +++ b/gui/GraphEditorWidget.h @@ -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 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 m_nodeMeters; }; diff --git a/include/warppipe/warppipe.hpp b/include/warppipe/warppipe.hpp index 62924ea..3f9c9fd 100644 --- a/include/warppipe/warppipe.hpp +++ b/include/warppipe/warppipe.hpp @@ -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 GetNodeVolume(NodeId node) const; + Status EnsureNodeMeter(NodeId node); + Status DisableNodeMeter(NodeId node); + Result NodeMeterPeak(NodeId node) const; + Result MeterPeak() const; + Result CreateLink(PortId output, PortId input, const LinkOptions& options); Result 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 Test_GetNodeVolume(NodeId node) const; + Status Test_SetNodeMeterPeak(NodeId node, float left, float right); + Status Test_SetMasterMeterPeak(float left, float right); #endif private: diff --git a/src/warppipe.cpp b/src/warppipe.cpp index 793e087..a361b4a 100644 --- a/src/warppipe.cpp +++ b/src/warppipe.cpp @@ -1,10 +1,13 @@ #include +#include #include +#include #include #include #include #include #include +#include #include #include @@ -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 peak_left{0.0f}; + std::atomic peak_right{0.0f}; +}; + +void NodeMeterProcess(void* data) { + auto* meter = static_cast(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(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 volume_states; + std::unordered_map meter_states; + std::unordered_set metered_nodes; + MeterState master_meter; + + std::unique_ptr master_meter_data; + std::unordered_map> live_meters; + uint32_t next_rule_id = 1; std::unordered_map route_rules; std::vector 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> links; std::unordered_map> 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(); + 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_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> meters; + { + std::lock_guard 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 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 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(); + 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_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 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 meter; + { + std::lock_guard 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 Client::NodeMeterPeak(NodeId node) const { + std::lock_guard 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 Client::MeterPeak() const { + std::lock_guard 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 Client::CreateLink(PortId output, PortId input, const LinkOptions& options) { Status status = impl_->EnsureConnected(); if (!status.ok()) { @@ -1805,6 +2070,38 @@ Result 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 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 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 diff --git a/tests/gui/warppipe_gui_tests.cpp b/tests/gui/warppipe_gui_tests.cpp index 826ebea..0eba604 100644 --- a/tests/gui/warppipe_gui_tests.cpp +++ b/tests/gui/warppipe_gui_tests.cpp @@ -1,5 +1,6 @@ #include +#include "../../gui/AudioLevelMeter.h" #include "../../gui/GraphEditorWidget.h" #include "../../gui/PresetManager.h" #include "../../gui/VolumeWidgets.h" @@ -12,6 +13,7 @@ #include #include #include +#include #include #include @@ -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(); + 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(); + REQUIRE(meters.size() >= 3); +} + TEST_CASE("volume state cleaned up on node deletion") { auto tc = TestClient::Create(); if (!tc.available()) { SUCCEED("PipeWire unavailable"); return; } diff --git a/tests/warppipe_tests.cpp b/tests/warppipe_tests.cpp index 8e0fd93..4b70857 100644 --- a/tests/warppipe_tests.cpp +++ b/tests/warppipe_tests.cpp @@ -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); +}