diff --git a/frontend/cmake/ui-components.cmake b/frontend/cmake/ui-components.cmake index 34f4c0e7fe3a1d..c59d05993b422c 100644 --- a/frontend/cmake/ui-components.cmake +++ b/frontend/cmake/ui-components.cmake @@ -44,6 +44,8 @@ target_sources( components/FocusList.hpp components/GameCaptureToolbar.cpp components/GameCaptureToolbar.hpp + components/GainReductionMeter.cpp + components/GainReductionMeter.hpp components/ImageSourceToolbar.cpp components/ImageSourceToolbar.hpp components/MediaControls.cpp diff --git a/frontend/components/GainReductionMeter.cpp b/frontend/components/GainReductionMeter.cpp new file mode 100644 index 00000000000000..6bab09e9d540c0 --- /dev/null +++ b/frontend/components/GainReductionMeter.cpp @@ -0,0 +1,187 @@ +#include "GainReductionMeter.hpp" + +#include + +#include + +#include +#include +#include +#include + +#include + +#include "moc_GainReductionMeter.cpp" + +GainReductionMeter::GainReductionMeter(QWidget *parent, obs_source_t *source) + : QWidget(parent), + weakSource(OBSGetWeakRef(source)) +{ + setFocusPolicy(Qt::NoFocus); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + + // Header row: "Gain Reduction" on the left, live dB on the right + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 4, 0, 8); + layout->setSpacing(4); + + auto *header = new QHBoxLayout(); + header->setContentsMargins(0, 0, 0, 0); + + auto *titleLabel = new QLabel(QTStr("Basic.Filters.GainReduction"), this); + valueLabel = new QLabel(QStringLiteral("0.0 dB"), this); + valueLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + + header->addWidget(titleLabel); + header->addStretch(); + header->addWidget(valueLabel); + layout->addLayout(header); + layout->addSpacing(14); + + // Stop polling cleanly if the filter is deleted while the dialog is open + if (source) { + destroyedSignal = OBSSignal(obs_source_get_signal_handler(source), "destroy", + &GainReductionMeter::obsSourceDestroyed, this); + } + + // ~30 Hz UI update; audio thread writes GR much faster via atomics + auto *timer = new QTimer(this); + timer->setTimerType(Qt::PreciseTimer); + connect(timer, &QTimer::timeout, this, &GainReductionMeter::tick); + timer->start(33); + + tick(); +} + +GainReductionMeter::~GainReductionMeter() = default; + +void GainReductionMeter::obsSourceDestroyed(void *data, calldata_t *) +{ + auto *self = static_cast(data); + // Bounce to the UI thread before touching Qt widgets + QMetaObject::invokeMethod(self, "onSourceDestroyed", Qt::QueuedConnection); +} + +void GainReductionMeter::onSourceDestroyed() +{ + weakSource = nullptr; + destroyedSignal.Disconnect(); + currentGainReductionDb = 0.0f; + peakHoldDb = 0.0f; + valueLabel->setText(QStringLiteral("0.0 dB")); + update(); +} + +QSize GainReductionMeter::minimumSizeHint() const +{ + return QSize(120, 40); +} + +QSize GainReductionMeter::sizeHint() const +{ + return QSize(200, 40); +} + +float GainReductionMeter::dbToBarWidth(float gainReductionDb, int width) const +{ + if (width <= 0) { + return 0.0f; + } + + // 0 dB -> empty; kMinimumDb (-60) -> full width. gainReductionDb is <= 0. + float amount = qBound(0.0f, gainReductionDb / kMinimumDb, 1.0f); + return amount * (float)width; +} + +void GainReductionMeter::pollGainReduction() +{ + OBSSource source = OBSGetStrongRef(weakSource); + if (!source) { + currentGainReductionDb = 0.0f; + return; + } + + // Compressor registers this in compressor_create + proc_handler_t *ph = obs_source_get_proc_handler(source); + if (!ph) { + currentGainReductionDb = 0.0f; + return; + } + + calldata_t cd = {}; + if (!proc_handler_call(ph, "get_gain_reduction", &cd)) { + calldata_free(&cd); + currentGainReductionDb = 0.0f; + return; + } + + // Negative = reduction applied; 0 = idle / below threshold + currentGainReductionDb = (float)calldata_float(&cd, "db"); + if (!std::isfinite(currentGainReductionDb) || currentGainReductionDb > 0.0f) { + currentGainReductionDb = 0.0f; + } else if (currentGainReductionDb < kMinimumDb) { + currentGainReductionDb = kMinimumDb; + } + + calldata_free(&cd); +} + +void GainReductionMeter::updatePeakHold(float gainReductionDb, uint64_t ts) +{ + const uint64_t holdNs = (uint64_t)(kPeakHoldDurationSec * 1000000000.0); + + // More reduction (more negative) always refreshes the peak + if (gainReductionDb < peakHoldDb) { + peakHoldDb = gainReductionDb; + peakHoldTimeNs = ts; + return; + } + + // After the hold window, let the peak fall back to the live value + if (peakHoldTimeNs == 0 || (ts - peakHoldTimeNs) >= holdNs) { + peakHoldDb = gainReductionDb; + peakHoldTimeNs = ts; + } +} + +void GainReductionMeter::tick() +{ + pollGainReduction(); + updatePeakHold(currentGainReductionDb, os_gettime_ns()); + + // Label shows live GR only; peak is visual-only on the bar + valueLabel->setText(QStringLiteral("%1 dB").arg(currentGainReductionDb, 0, 'f', 1)); + update(); +} + +void GainReductionMeter::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + + const int barHeight = 10; + const int barY = height() - barHeight - 4; + const int barWidth = width(); + + if (barWidth <= 0 || barY < 0) { + return; + } + + const QColor background(0x2a, 0x2a, 0x2a); + const QColor fill(0xe6, 0xb8, 0x00); // amber fill = live GR + const QColor peakTick(0xff, 0xf0, 0xa0); // lighter tick = peak hold + + painter.fillRect(0, barY, barWidth, barHeight, background); + + // Live fill grows left -> right as reduction increases + const int fillWidth = (int)dbToBarWidth(currentGainReductionDb, barWidth); + if (fillWidth > 0) { + painter.fillRect(0, barY, fillWidth, barHeight, fill); + } + + // Peak tick sits at the recent maximum reduction + const int peakX = (int)dbToBarWidth(peakHoldDb, barWidth); + if (peakHoldDb < -0.05f && peakX > 0) { + const int tickWidth = qMax(2, barWidth / 120); + painter.fillRect(qMin(peakX, barWidth - tickWidth), barY, tickWidth, barHeight, peakTick); + } +} diff --git a/frontend/components/GainReductionMeter.hpp b/frontend/components/GainReductionMeter.hpp new file mode 100644 index 00000000000000..014e97bdafeb07 --- /dev/null +++ b/frontend/components/GainReductionMeter.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include + +#include +#include + +// Live gain-reduction meter shown above compressor filter properties. +// Polls the filter's get_gain_reduction proc handler on a timer. +class GainReductionMeter : public QWidget { + Q_OBJECT + +public: + // source is the compressor filter instance to meter + explicit GainReductionMeter(QWidget *parent = nullptr, obs_source_t *source = nullptr); + ~GainReductionMeter() override; + + QSize minimumSizeHint() const override; + QSize sizeHint() const override; + +protected: + // Draws the amber GR bar and peak-hold tick at the bottom of the widget + void paintEvent(QPaintEvent *event) override; + +private slots: + // UI-thread cleanup when the filter source is destroyed + void onSourceDestroyed(); + // Timer callback: poll GR, update peak hold + label, repaint + void tick(); + +private: + // OBS signal callback (may run off the UI thread) -> queues onSourceDestroyed + static void obsSourceDestroyed(void *data, calldata_t *); + + // Reads current GR dB from the filter via proc_handler + void pollGainReduction(); + // Keeps the most-reduced (most negative) value for kPeakHoldDurationSec + void updatePeakHold(float gainReductionDb, uint64_t ts); + // Maps GR dB (0 .. kMinimumDb) to a horizontal pixel width + float dbToBarWidth(float gainReductionDb, int width) const; + + OBSWeakSource weakSource; + OBSSignal destroyedSignal; + + QLabel *valueLabel = nullptr; // live numeric readout, e.g. "-6.2 dB" + + float currentGainReductionDb = 0.0f; // live value (bar fill + label) + float peakHoldDb = 0.0f; // peak marker only (not shown in label) + uint64_t peakHoldTimeNs = 0; // when peakHoldDb was last set + + static constexpr float kMinimumDb = -60.0f; // right edge of the bar scale + static constexpr float kPeakHoldDurationSec = 1.5f; // how long the peak tick sticks +}; diff --git a/frontend/data/locale/en-US.ini b/frontend/data/locale/en-US.ini index b5cc6384cf0834..98c55fe49d880e 100644 --- a/frontend/data/locale/en-US.ini +++ b/frontend/data/locale/en-US.ini @@ -710,6 +710,7 @@ Basic.Filters.EffectFilters="Effect Filters" Basic.Filters.Title="Filters for '%1'" Basic.Filters.AddFilter.Title="Filter name" Basic.Filters.AddFilter.Text="Please specify the name of the filter" +Basic.Filters.GainReduction="Gain Reduction" # transform window Basic.TransformWindow="Scene Item Transform" diff --git a/frontend/dialogs/OBSBasicFilters.cpp b/frontend/dialogs/OBSBasicFilters.cpp index f5ae11b2797555..940709d89023c8 100644 --- a/frontend/dialogs/OBSBasicFilters.cpp +++ b/frontend/dialogs/OBSBasicFilters.cpp @@ -17,6 +17,7 @@ #include "OBSBasicFilters.hpp" +#include #include #include #include @@ -27,6 +28,8 @@ #include #include +#include + #include #ifdef _WIN32 @@ -241,6 +244,12 @@ void OBSBasicFilters::UpdatePropertiesView(int row, bool async) * * macOS might be especially affected as it doesn't switch keyboard focus * to buttons like Windows does. */ + // Tear down GR meter with the properties view when selection changes + if (gainReductionMeter) { + gainReductionMeter->hide(); + gainReductionMeter->deleteLater(); + gainReductionMeter = nullptr; + } if (view) { view->hide(); view->deleteLater(); @@ -270,6 +279,15 @@ void OBSBasicFilters::UpdatePropertiesView(int row, bool async) view->setMinimumHeight(150); UpdateSplitter(); + + // obs_properties has no meter widget type, so host GR UI here for compressor + const char *id = obs_source_get_id(filter); + if (id && strcmp(id, "compressor_filter") == 0) { + gainReductionMeter = new GainReductionMeter(ui->propertiesFrame, filter); + ui->propertiesLayout->addWidget(gainReductionMeter); + gainReductionMeter->show(); + } + ui->propertiesLayout->addWidget(view); view->show(); } diff --git a/frontend/dialogs/OBSBasicFilters.hpp b/frontend/dialogs/OBSBasicFilters.hpp index 7a37a93e1fc3bb..6508abd188757d 100644 --- a/frontend/dialogs/OBSBasicFilters.hpp +++ b/frontend/dialogs/OBSBasicFilters.hpp @@ -21,6 +21,7 @@ #include +class GainReductionMeter; class OBSBasic; class OBSPropertiesView; @@ -33,6 +34,8 @@ class OBSBasicFilters : public QDialog { std::unique_ptr ui; OBSSource source; OBSPropertiesView *view = nullptr; + // Shown only while a compressor_filter is selected in this dialog + GainReductionMeter *gainReductionMeter = nullptr; std::vector obsSignals; OBSSignal updatePropertiesSignal; diff --git a/plugins/obs-filters/compressor-filter.c b/plugins/obs-filters/compressor-filter.c index 9c1aa35c80e6c7..292a8cc8605fd3 100644 --- a/plugins/obs-filters/compressor-filter.c +++ b/plugins/obs-filters/compressor-filter.c @@ -1,6 +1,7 @@ #include #include #include +#include #include #include @@ -75,6 +76,13 @@ struct compressor_data { float envelope; float slope; + /* Metering: float GR dB stored as long bits for atomic cross-thread reads. + * Written on the audio thread; read from the UI via get_gain_reduction. */ + volatile long gain_reduction_db_bits; + /* Set true each audio block that runs process_compression; cleared in video_tick. + * If still false at tick time, the filter is idle/disabled -> publish 0 dB. */ + volatile bool gain_reduction_updated; + pthread_mutex_t sidechain_update_mutex; uint64_t sidechain_check_time; obs_weak_source_t *weak_sidechain; @@ -135,6 +143,29 @@ static inline float gain_coefficient(uint32_t sample_rate, float time) return (float)exp(-1.0f / (sample_rate * time)); } +/* Publish GR for the UI (audio thread). Bitcast float <-> long for os_atomic_*. */ +static inline void store_gain_reduction_db(struct compressor_data *cd, float db) +{ + long bits = 0; + memcpy(&bits, &db, sizeof(float)); + os_atomic_set_long(&cd->gain_reduction_db_bits, bits); +} + +static inline float load_gain_reduction_db(struct compressor_data *cd) +{ + long bits = os_atomic_load_long(&cd->gain_reduction_db_bits); + float db = 0.0f; + memcpy(&db, &bits, sizeof(float)); + return db; +} + +/* proc_handler entry: "void get_gain_reduction(out float db)" */ +static void get_gain_reduction(void *data, calldata_t *cd) +{ + struct compressor_data *cd_data = data; + calldata_set_float(cd, "db", load_gain_reduction_db(cd_data)); +} + static const char *compressor_name(void *unused) { UNUSED_PARAMETER(unused); @@ -261,6 +292,10 @@ static void *compressor_create(obs_data_t *settings, obs_source_t *filter) return NULL; } + /* Let the Filters UI (and scripts) read live gain reduction */ + proc_handler_t *ph = obs_source_get_proc_handler(filter); + proc_handler_add(ph, "void get_gain_reduction(out float db)", get_gain_reduction, cd); + compressor_update(cd, settings); return cd; } @@ -353,12 +388,19 @@ static void analyze_sidechain(struct compressor_data *cd, const uint32_t num_sam cd->envelope = cd->envelope_buf[num_samples - 1]; } -static inline void process_compression(const struct compressor_data *cd, float **samples, uint32_t num_samples) +static inline void process_compression(struct compressor_data *cd, float **samples, uint32_t num_samples) { + /* Track the strongest reduction in this block for the meter (most negative dB) */ + float min_gain_db = 0.0f; + for (size_t i = 0; i < num_samples; ++i) { const float env_db = mul_to_db(cd->envelope_buf[i]); - float gain = cd->slope * (cd->threshold - env_db); - gain = db_to_mul(fminf(0, gain)); + /* keep gain_db so we can meter it */ + float gain_db = fminf(0.0f, cd->slope * (cd->threshold - env_db)); + float gain = db_to_mul(gain_db); + + if (gain_db < min_gain_db) + min_gain_db = gain_db; for (size_t c = 0; c < cd->num_channels; ++c) { if (samples[c]) { @@ -366,6 +408,9 @@ static inline void process_compression(const struct compressor_data *cd, float * } } } + + store_gain_reduction_db(cd, min_gain_db); + os_atomic_set_bool(&cd->gain_reduction_updated, true); } static void compressor_tick(void *data, float seconds) @@ -373,6 +418,11 @@ static void compressor_tick(void *data, float seconds) struct compressor_data *cd = data; char *new_name = NULL; + /* os_atomic_set_bool returns the previous value. If it was already false, + * no audio block ran since the last tick (filter disabled / no audio) -> 0 dB. */ + if (!os_atomic_set_bool(&cd->gain_reduction_updated, false)) + store_gain_reduction_db(cd, 0.0f); + pthread_mutex_lock(&cd->sidechain_update_mutex); if (cd->sidechain_name && !cd->weak_sidechain) {