diff --git a/CMakeLists.txt b/CMakeLists.txt
index ef29d3b1..aec82262 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -209,6 +209,8 @@ target_sources(
lib/utils/filter-combo-box.hpp
lib/utils/first-run-wizard.cpp
lib/utils/first-run-wizard.hpp
+ lib/utils/first-run-wizard-audio.cpp
+ lib/utils/first-run-wizard-audio.hpp
lib/utils/first-run-wizard-helpers.hpp
lib/utils/first-run-wizard-sequence.cpp
lib/utils/first-run-wizard-sequence.hpp
diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini
index e7e71792..d73b999a 100644
--- a/data/locale/en-US.ini
+++ b/data/locale/en-US.ini
@@ -2685,6 +2685,22 @@ FirstRunWizard.template.title="Choose an Automation Type"
FirstRunWizard.template.subtitle="Select the kind of macro you would like to create."
FirstRunWizard.template.window="Switch to a scene when a window comes into focus"
FirstRunWizard.template.sequence="Run a timed sequence of scene switches"
+FirstRunWizard.template.audio="Show or hide a source based on audio activity"
+
+FirstRunWizard.audio.source.title="Choose an Audio Source"
+FirstRunWizard.audio.source.subtitle="Select the audio source to monitor and configure when it should be considered active."
+FirstRunWizard.audio.source.sourceRow="Audio source:{{source}}"
+FirstRunWizard.audio.source.thresholdRow="Show when volume is above{{threshold}}for{{duration}}"
+
+FirstRunWizard.audio.target.title="Choose a Target Source"
+FirstRunWizard.audio.target.subtitle="Select the source to show when audio is active and hide when it is not."
+FirstRunWizard.audio.target.sourceRow="Source to show/hide:{{source}}"
+
+FirstRunWizard.audio.review.title="Review Your Macro"
+FirstRunWizard.audio.review.subtitle="Click Back to make changes, or Finish to create the macro."
+FirstRunWizard.audio.review.summary="When %1 output volume is above %2 dB for %3 : Show %4 on the current sceneOtherwise: Hide %4 on the current scene"
+FirstRunWizard.audio.review.errorTitle="Macro Creation Failed"
+FirstRunWizard.audio.review.errorBody="The audio macro could not be created. Please check the OBS log for details."
FirstRunWizard.scene.title="Choose a Target Scene"
FirstRunWizard.scene.subtitle="Which OBS scene should become active when your chosen window comes into focus?"
diff --git a/lib/utils/first-run-wizard-audio.cpp b/lib/utils/first-run-wizard-audio.cpp
new file mode 100644
index 00000000..c13a1d35
--- /dev/null
+++ b/lib/utils/first-run-wizard-audio.cpp
@@ -0,0 +1,425 @@
+#include "first-run-wizard-audio.hpp"
+#include "first-run-wizard-helpers.hpp"
+
+#include "layout-helpers.hpp"
+#include "log-helper.hpp"
+#include "macro-settings.hpp"
+#include "selection-helpers.hpp"
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+namespace advss {
+
+namespace wiz {
+
+// Builds the obs_data blob for an audio volume condition,
+// matching MacroConditionAudio::Save() output.
+//
+// Checks output volume (checkType 0) ABOVE (outputCondition 0) the given
+// threshold in dB, held for at least `duration`.
+//
+// {
+// "segmentSettings": { "enabled": true, "version": 2 },
+// "id": "audio",
+// "logic": 0,
+// "durationModifier": {
+// "time_constraint": 1, // AT_LEAST
+// "seconds":
+// },
+// "audioSource": { "type": 0, "name": "" },
+// "monitor": 0,
+// "volume": { "value": 0.0, "type": 0 },
+// "syncOffset": { "value": 0, "type": 0 },
+// "balance": { "value": 0.5, "type": 0 },
+// "checkType": 0, // OUTPUT_VOLUME
+// "outputCondition": 0, // ABOVE
+// "volumeCondition": 0,
+// "useDb": true,
+// "volumeDB": { "value": , "type": 0 },
+// "version": 3
+// }
+static OBSDataAutoRelease BuildAudioConditionData(const QString &sourceName,
+ double thresholdDb,
+ const Duration &duration)
+{
+ OBSDataAutoRelease seg = obs_data_create();
+ obs_data_set_bool(seg, "enabled", true);
+ obs_data_set_int(seg, "version", 2);
+
+ OBSDataAutoRelease durMod = obs_data_create();
+ obs_data_set_int(durMod, "time_constraint", 1);
+ duration.Save(durMod, "seconds");
+
+ OBSDataAutoRelease audioSrc = obs_data_create();
+ obs_data_set_int(audioSrc, "type", 0);
+ obs_data_set_string(audioSrc, "name", sourceName.toUtf8().constData());
+
+ OBSDataAutoRelease volume = obs_data_create();
+ obs_data_set_double(volume, "value", 0.0);
+ obs_data_set_int(volume, "type", 0);
+
+ OBSDataAutoRelease syncOffset = obs_data_create();
+ obs_data_set_int(syncOffset, "value", 0);
+ obs_data_set_int(syncOffset, "type", 0);
+
+ OBSDataAutoRelease balance = obs_data_create();
+ obs_data_set_double(balance, "value", 0.5);
+ obs_data_set_int(balance, "type", 0);
+
+ OBSDataAutoRelease volumeDB = obs_data_create();
+ obs_data_set_double(volumeDB, "value", thresholdDb);
+ obs_data_set_int(volumeDB, "type", 0);
+
+ OBSDataAutoRelease data = obs_data_create();
+ obs_data_set_obj(data, "segmentSettings", seg);
+ obs_data_set_string(data, "id", "audio");
+ obs_data_set_int(data, "logic", 0);
+ obs_data_set_obj(data, "durationModifier", durMod);
+ obs_data_set_obj(data, "audioSource", audioSrc);
+ obs_data_set_int(data, "monitor", 0);
+ obs_data_set_obj(data, "volume", volume);
+ obs_data_set_obj(data, "syncOffset", syncOffset);
+ obs_data_set_obj(data, "balance", balance);
+ obs_data_set_int(data, "checkType", 0);
+ obs_data_set_int(data, "outputCondition", 0);
+ obs_data_set_int(data, "volumeCondition", 0);
+ obs_data_set_bool(data, "useDb", true);
+ obs_data_set_obj(data, "volumeDB", volumeDB);
+ obs_data_set_int(data, "version", 3);
+
+ return data;
+}
+
+// Builds the obs_data blob for a scene-visibility action on the current scene,
+// matching MacroActionSceneVisibility::Save() output.
+//
+// sceneSelection type 3 = current scene.
+// action 0 = SHOW, action 1 = HIDE.
+static OBSDataAutoRelease BuildVisibilityActionData(const QString &sourceName,
+ int action)
+{
+ OBSDataAutoRelease seg = obs_data_create();
+ obs_data_set_bool(seg, "enabled", true);
+ obs_data_set_int(seg, "version", 2);
+
+ OBSDataAutoRelease sceneSel = obs_data_create();
+ obs_data_set_int(sceneSel, "type", 3); // current scene
+ obs_data_set_string(sceneSel, "canvasSelection", "Main");
+
+ OBSDataAutoRelease itemSel = obs_data_create();
+ obs_data_set_int(itemSel, "type", 0);
+ obs_data_set_int(itemSel, "idxType", 0);
+ obs_data_set_int(itemSel, "idx", 0);
+ obs_data_set_string(itemSel, "item", sourceName.toUtf8().constData());
+
+ OBSDataAutoRelease data = obs_data_create();
+ obs_data_set_obj(data, "segmentSettings", seg);
+ obs_data_set_string(data, "id", "scene_visibility");
+ obs_data_set_obj(data, "sceneSelection", sceneSel);
+ obs_data_set_obj(data, "sceneItemSelection", itemSel);
+ obs_data_set_bool(data, "updateTransition", false);
+ obs_data_set_int(data, "transitionType", 0);
+ obs_data_set_string(data, "transition", "");
+ obs_data_set_bool(data, "updateDuration", false);
+ Duration().Save(data, "duration");
+ obs_data_set_int(data, "action", action);
+
+ return data;
+}
+
+// ===========================================================================
+// AudioSourcePage
+// ===========================================================================
+
+AudioSourcePage::AudioSourcePage(QWidget *parent)
+ : QWizardPage(parent),
+ _sourceCombo(new QComboBox(this)),
+ _thresholdSpinbox(new QDoubleSpinBox(this)),
+ _durationSelection(new DurationSelection(this, true, 0.0)),
+ _volmeterLayout(new QVBoxLayout)
+{
+ setTitle(obs_module_text("FirstRunWizard.audio.source.title"));
+ setSubTitle(obs_module_text("FirstRunWizard.audio.source.subtitle"));
+
+ _sourceCombo->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
+
+ _thresholdSpinbox->setRange(-100.0, 0.0);
+ _thresholdSpinbox->setValue(-24.0);
+ _thresholdSpinbox->setDecimals(1);
+ _thresholdSpinbox->setSingleStep(1.0);
+ _thresholdSpinbox->setSuffix(" dB");
+
+ _durationSelection->SetDuration(Duration(1.0));
+
+ registerField("audioSourceName*", _sourceCombo, "currentText",
+ SIGNAL(currentTextChanged(QString)));
+
+ connect(_sourceCombo, &QComboBox::currentTextChanged, this,
+ &QWizardPage::completeChanged);
+ connect(_sourceCombo, &QComboBox::currentTextChanged, this,
+ &AudioSourcePage::UpdateVolmeter);
+ connect(_thresholdSpinbox,
+ QOverload::of(&QDoubleSpinBox::valueChanged), this,
+ &AudioSourcePage::SyncSliderFromSpinbox);
+
+ auto *sourceRow = new QHBoxLayout;
+ PlaceWidgets(obs_module_text("FirstRunWizard.audio.source.sourceRow"),
+ sourceRow, {{"{{source}}", _sourceCombo}}, false);
+
+ auto *thresholdRow = new QHBoxLayout;
+ PlaceWidgets(
+ obs_module_text("FirstRunWizard.audio.source.thresholdRow"),
+ thresholdRow,
+ {{"{{threshold}}", _thresholdSpinbox},
+ {"{{duration}}", _durationSelection}},
+ false);
+
+ auto *layout = new QVBoxLayout(this);
+ layout->addLayout(sourceRow);
+ layout->addLayout(thresholdRow);
+ layout->addLayout(_volmeterLayout);
+ layout->addStretch();
+}
+
+void AudioSourcePage::initializePage()
+{
+ _sourceCombo->clear();
+ PopulateAudioSelection(_sourceCombo, false);
+}
+
+void AudioSourcePage::UpdateVolmeter()
+{
+ delete _volControl;
+ _volControl = nullptr;
+
+ OBSSourceAutoRelease source = obs_get_source_by_name(
+ _sourceCombo->currentText().toUtf8().constData());
+ if (!source) {
+ return;
+ }
+
+ _volControl = new VolControl(source.Get());
+ _volmeterLayout->addWidget(_volControl);
+
+ connect(_volControl->GetSlider(), &DoubleSlider::DoubleValChanged, this,
+ &AudioSourcePage::SyncSpinboxFromSlider);
+ SyncSliderFromSpinbox();
+}
+
+void AudioSourcePage::SyncSpinboxFromSlider()
+{
+ if (!_volControl) {
+ return;
+ }
+ const QSignalBlocker blocker(_thresholdSpinbox);
+ _thresholdSpinbox->setValue(_volControl->GetSlider()->DoubleValue() -
+ 100.0);
+}
+
+void AudioSourcePage::SyncSliderFromSpinbox()
+{
+ if (!_volControl) {
+ return;
+ }
+ const QSignalBlocker blocker(_volControl->GetSlider());
+ _volControl->GetSlider()->SetDoubleVal(_thresholdSpinbox->value() +
+ 100.0);
+}
+
+bool AudioSourcePage::isComplete() const
+{
+ return _sourceCombo->count() > 0 &&
+ !_sourceCombo->currentText().isEmpty();
+}
+
+Duration AudioSourcePage::GetDuration() const
+{
+ return _durationSelection->GetDuration();
+}
+
+// ===========================================================================
+// AudioTargetPage
+// ===========================================================================
+
+AudioTargetPage::AudioTargetPage(QWidget *parent)
+ : QWizardPage(parent),
+ _sourceCombo(new QComboBox(this))
+{
+ setTitle(obs_module_text("FirstRunWizard.audio.target.title"));
+ setSubTitle(obs_module_text("FirstRunWizard.audio.target.subtitle"));
+
+ _sourceCombo->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
+
+ registerField("audioTargetSource*", _sourceCombo, "currentText",
+ SIGNAL(currentTextChanged(QString)));
+
+ connect(_sourceCombo, &QComboBox::currentTextChanged, this,
+ &QWizardPage::completeChanged);
+
+ auto *row = new QHBoxLayout;
+ PlaceWidgets(obs_module_text("FirstRunWizard.audio.target.sourceRow"),
+ row, {{"{{source}}", _sourceCombo}}, false);
+
+ auto *layout = new QVBoxLayout(this);
+ layout->addLayout(row);
+ layout->addStretch();
+}
+
+void AudioTargetPage::initializePage()
+{
+ QStringList names;
+ auto enumScenes = [](void *param, obs_source_t *src) -> bool {
+ auto *list = reinterpret_cast(param);
+ obs_scene_t *scene = obs_scene_from_source(src);
+ obs_scene_enum_items(
+ scene,
+ [](obs_scene_t *, obs_sceneitem_t *item,
+ void *p) -> bool {
+ auto *l = reinterpret_cast(p);
+ OBSSource s = obs_sceneitem_get_source(item);
+ if (s) {
+ *l << obs_source_get_name(s);
+ }
+ return true;
+ },
+ list);
+ return true;
+ };
+ obs_enum_scenes(enumScenes, &names);
+ names.sort(Qt::CaseInsensitive);
+ names.removeDuplicates();
+
+ const QString prev = _sourceCombo->currentText();
+ _sourceCombo->clear();
+ _sourceCombo->addItems(names);
+ const int idx = _sourceCombo->findText(prev);
+ if (idx >= 0) {
+ _sourceCombo->setCurrentIndex(idx);
+ }
+}
+
+bool AudioTargetPage::isComplete() const
+{
+ return _sourceCombo->count() > 0 &&
+ !_sourceCombo->currentText().isEmpty();
+}
+
+// ===========================================================================
+// AudioReviewPage
+// ===========================================================================
+
+AudioReviewPage::AudioReviewPage(QWidget *parent, std::shared_ptr ¯o)
+ : QWizardPage(parent),
+ _summary(new QLabel(this)),
+ _macro(macro)
+{
+ setTitle(obs_module_text("FirstRunWizard.audio.review.title"));
+ setSubTitle(obs_module_text("FirstRunWizard.audio.review.subtitle"));
+
+ detail::setupSummaryLabel(_summary);
+
+ auto *layout = new QVBoxLayout(this);
+ layout->addWidget(_summary);
+ layout->addStretch();
+}
+
+void AudioReviewPage::initializePage()
+{
+ const QString audioSource = field("audioSourceName").toString();
+ const QString targetSource = field("audioTargetSource").toString();
+
+ auto *sourcePage = qobject_cast(
+ wizard()->page(PAGE_AUDIO_SOURCE));
+ const double thresholdDb = sourcePage ? sourcePage->GetThresholdDb()
+ : -24.0;
+ const Duration duration = sourcePage ? sourcePage->GetDuration()
+ : Duration(1.0);
+
+ _summary->setText(
+ QString(obs_module_text("FirstRunWizard.audio.review.summary"))
+ .arg(audioSource.toHtmlEscaped())
+ .arg(thresholdDb, 0, 'f', 1)
+ .arg(QString::fromStdString(duration.ToString()))
+ .arg(targetSource.toHtmlEscaped()));
+}
+
+bool AudioReviewPage::validatePage()
+{
+ const QString audioSource = field("audioSourceName").toString();
+ const QString targetSource = field("audioTargetSource").toString();
+ const std::string name =
+ ("Audio: " + audioSource + " -> " + targetSource).toStdString();
+
+ auto *sourcePage = qobject_cast(
+ wizard()->page(PAGE_AUDIO_SOURCE));
+ const double thresholdDb = sourcePage ? sourcePage->GetThresholdDb()
+ : -24.0;
+ const Duration duration = sourcePage ? sourcePage->GetDuration()
+ : Duration(1.0);
+
+ _macro = std::make_shared(name, GetGlobalMacroSettings());
+ if (!_macro) {
+ blog(LOG_WARNING,
+ "FirstRunWizard: audio macro allocation failed");
+ return true;
+ }
+
+ _macro->SetActionConditionSplitterPosition(
+ {QWIDGETSIZE_MAX / 2, QWIDGETSIZE_MAX / 2});
+ _macro->SetElseActionSplitterPosition(
+ {QWIDGETSIZE_MAX / 2, QWIDGETSIZE_MAX / 2});
+
+ OBSDataAutoRelease condData =
+ BuildAudioConditionData(audioSource, thresholdDb, duration);
+ if (!detail::addCondition(_macro.get(), "audio", condData)) {
+ _macro.reset();
+ QMessageBox::warning(
+ this,
+ obs_module_text(
+ "FirstRunWizard.audio.review.errorTitle"),
+ obs_module_text(
+ "FirstRunWizard.audio.review.errorBody"));
+ return true;
+ }
+
+ OBSDataAutoRelease showData =
+ BuildVisibilityActionData(targetSource, 0);
+ if (!detail::addAction(_macro.get(), "scene_visibility", showData)) {
+ _macro.reset();
+ QMessageBox::warning(
+ this,
+ obs_module_text(
+ "FirstRunWizard.audio.review.errorTitle"),
+ obs_module_text(
+ "FirstRunWizard.audio.review.errorBody"));
+ return true;
+ }
+
+ OBSDataAutoRelease hideData =
+ BuildVisibilityActionData(targetSource, 1);
+ if (!detail::addElseAction(_macro.get(), "scene_visibility",
+ hideData)) {
+ _macro.reset();
+ QMessageBox::warning(
+ this,
+ obs_module_text(
+ "FirstRunWizard.audio.review.errorTitle"),
+ obs_module_text(
+ "FirstRunWizard.audio.review.errorBody"));
+ return true;
+ }
+
+ blog(LOG_INFO, "FirstRunWizard: created audio macro '%s'",
+ name.c_str());
+ return true;
+}
+
+} // namespace wiz
+
+} // namespace advss
diff --git a/lib/utils/first-run-wizard-audio.hpp b/lib/utils/first-run-wizard-audio.hpp
new file mode 100644
index 00000000..3d733def
--- /dev/null
+++ b/lib/utils/first-run-wizard-audio.hpp
@@ -0,0 +1,83 @@
+#pragma once
+
+#include "duration-control.hpp"
+#include "duration.hpp"
+#include "first-run-wizard.hpp"
+#include "volume-control.hpp"
+
+#include
+#include
+#include
+#include
+
+#include
+
+namespace advss {
+
+namespace wiz {
+
+// ---------------------------------------------------------------------------
+// AudioSourcePage
+// Registers wizard field "audioSourceName" (QString).
+// Volume threshold and duration are read back via getters.
+// ---------------------------------------------------------------------------
+class AudioSourcePage : public QWizardPage {
+ Q_OBJECT
+public:
+ explicit AudioSourcePage(QWidget *parent = nullptr);
+ void initializePage() override;
+ bool isComplete() const override;
+ int nextId() const override { return PAGE_AUDIO_TARGET; }
+
+ double GetThresholdDb() const { return _thresholdSpinbox->value(); }
+ Duration GetDuration() const;
+
+private slots:
+ void UpdateVolmeter();
+ void SyncSpinboxFromSlider();
+ void SyncSliderFromSpinbox();
+
+private:
+ QComboBox *_sourceCombo;
+ QDoubleSpinBox *_thresholdSpinbox;
+ DurationSelection *_durationSelection;
+ QVBoxLayout *_volmeterLayout;
+ VolControl *_volControl = nullptr;
+};
+
+// ---------------------------------------------------------------------------
+// AudioTargetPage
+// Registers wizard field "audioTargetSource" (QString).
+// ---------------------------------------------------------------------------
+class AudioTargetPage : public QWizardPage {
+ Q_OBJECT
+public:
+ explicit AudioTargetPage(QWidget *parent = nullptr);
+ void initializePage() override;
+ bool isComplete() const override;
+ int nextId() const override { return PAGE_AUDIO_REVIEW; }
+
+private:
+ QComboBox *_sourceCombo;
+};
+
+// ---------------------------------------------------------------------------
+// AudioReviewPage
+// Displays a summary and builds the macro from wizard fields on Finish.
+// ---------------------------------------------------------------------------
+class AudioReviewPage : public QWizardPage {
+ Q_OBJECT
+public:
+ explicit AudioReviewPage(QWidget *parent,
+ std::shared_ptr ¯o);
+ void initializePage() override;
+ bool validatePage() override;
+ int nextId() const override { return PAGE_DONE; }
+
+private:
+ QLabel *_summary;
+ std::shared_ptr &_macro;
+};
+
+} // namespace wiz
+} // namespace advss
diff --git a/lib/utils/first-run-wizard.cpp b/lib/utils/first-run-wizard.cpp
index 45310c62..9fe2edf8 100644
--- a/lib/utils/first-run-wizard.cpp
+++ b/lib/utils/first-run-wizard.cpp
@@ -1,4 +1,5 @@
#include "first-run-wizard.hpp"
+#include "first-run-wizard-audio.hpp"
#include "first-run-wizard-sequence.hpp"
#include "first-run-wizard-window.hpp"
@@ -74,7 +75,9 @@ TemplatePage::TemplatePage(QWidget *parent)
_windowRadio(new QRadioButton(
obs_module_text("FirstRunWizard.template.window"), this)),
_sequenceRadio(new QRadioButton(
- obs_module_text("FirstRunWizard.template.sequence"), this))
+ obs_module_text("FirstRunWizard.template.sequence"), this)),
+ _audioRadio(new QRadioButton(
+ obs_module_text("FirstRunWizard.template.audio"), this))
{
setTitle(obs_module_text("FirstRunWizard.template.title"));
setSubTitle(obs_module_text("FirstRunWizard.template.subtitle"));
@@ -84,6 +87,7 @@ TemplatePage::TemplatePage(QWidget *parent)
auto *layout = new QVBoxLayout(this);
layout->addWidget(_windowRadio);
layout->addWidget(_sequenceRadio);
+ layout->addWidget(_audioRadio);
layout->addStretch();
}
@@ -92,6 +96,9 @@ int TemplatePage::nextId() const
if (_sequenceRadio->isChecked()) {
return PAGE_SEQ_TRIGGER;
}
+ if (_audioRadio->isChecked()) {
+ return PAGE_AUDIO_SOURCE;
+ }
return PAGE_WINDOW_SCENE;
}
@@ -133,6 +140,9 @@ FirstRunWizard::FirstRunWizard(QWidget *parent) : QWizard(parent)
setPage(PAGE_SEQ_TRIGGER, new SequenceTriggerPage(this));
setPage(PAGE_SEQ_SCENES, new SequenceScenesPage(this));
setPage(PAGE_SEQ_REVIEW, new SequenceReviewPage(this, _macro));
+ setPage(PAGE_AUDIO_SOURCE, new AudioSourcePage(this));
+ setPage(PAGE_AUDIO_TARGET, new AudioTargetPage(this));
+ setPage(PAGE_AUDIO_REVIEW, new AudioReviewPage(this, _macro));
setPage(PAGE_DONE, new DonePage(this));
setStartId(PAGE_WELCOME);
diff --git a/lib/utils/first-run-wizard.hpp b/lib/utils/first-run-wizard.hpp
index 4aba3d48..4ddde3b7 100644
--- a/lib/utils/first-run-wizard.hpp
+++ b/lib/utils/first-run-wizard.hpp
@@ -26,6 +26,9 @@ enum WizardPageId {
PAGE_SEQ_TRIGGER,
PAGE_SEQ_SCENES,
PAGE_SEQ_REVIEW,
+ PAGE_AUDIO_SOURCE,
+ PAGE_AUDIO_TARGET,
+ PAGE_AUDIO_REVIEW,
PAGE_DONE,
};
@@ -52,6 +55,7 @@ public:
private:
QRadioButton *_windowRadio;
QRadioButton *_sequenceRadio;
+ QRadioButton *_audioRadio;
};
// ---------------------------------------------------------------------------