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 scene

Otherwise:

  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; }; // ---------------------------------------------------------------------------