diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index b02b8b5c..ff60e08d 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -879,6 +879,15 @@ AdvSceneSwitcher.action.audio.fade.rate="{{fade}}Fade{{fadeTypes}}{{rate}}per se AdvSceneSwitcher.action.audio.fade.wait="Wait for fade to complete." AdvSceneSwitcher.action.audio.fade.abort="Abort already active fade." AdvSceneSwitcher.action.audio.entry="{{actions}}{{audioSources}}{{volume}}{{volumeDB}}{{percentDBToggle}}{{syncOffset}}{{monitorTypes}}{{track}}" +AdvSceneSwitcher.action.playAudio="Play Audio File" +AdvSceneSwitcher.action.playAudio.file.browse="Browse for audio file" +AdvSceneSwitcher.action.playAudio.layout.volume="Volume{{volumeDB}}" +AdvSceneSwitcher.action.playAudio.layout.monitor="Monitoring{{monitorTypes}}" +AdvSceneSwitcher.action.playAudio.wait="Wait for playback to complete" +AdvSceneSwitcher.action.playAudio.monitorUnavailable="Audio monitoring not available" +AdvSceneSwitcher.action.playAudio.tracks="Tracks" +AdvSceneSwitcher.action.playAudio.layout.startOffset="{{useStartOffset}}Start at{{startOffset}}" +AdvSceneSwitcher.action.playAudio.layout.playbackDuration="{{useDuration}}Play for{{playbackDuration}}(0 = until end)" AdvSceneSwitcher.action.recording="Recording" AdvSceneSwitcher.action.recording.type.stop="Stop recording" AdvSceneSwitcher.action.recording.type.start="Start recording" diff --git a/plugins/base/CMakeLists.txt b/plugins/base/CMakeLists.txt index e9eb66a9..a804dbf6 100644 --- a/plugins/base/CMakeLists.txt +++ b/plugins/base/CMakeLists.txt @@ -25,6 +25,8 @@ target_sources( macro-action-obs-settings.hpp macro-action-osc.cpp macro-action-osc.hpp + macro-action-play-audio.cpp + macro-action-play-audio.hpp macro-action-plugin-state.cpp macro-action-plugin-state.hpp macro-action-profile.cpp diff --git a/plugins/base/macro-action-play-audio.cpp b/plugins/base/macro-action-play-audio.cpp new file mode 100644 index 00000000..a6d53f57 --- /dev/null +++ b/plugins/base/macro-action-play-audio.cpp @@ -0,0 +1,443 @@ +#include "macro-action-play-audio.hpp" +#include "audio-helpers.hpp" +#include "layout-helpers.hpp" +#include "macro-helpers.hpp" +#include "sync-helpers.hpp" + +#include +#include +#include + +namespace advss { + +// Use a high output channel index that is unlikely to be claimed by OBS or +// other plugins. OBS supports channels 0-63; channel 0 is the main scene. +static constexpr uint32_t kPlaybackOutputChannel = 63; + +const std::string MacroActionPlayAudio::id = "play_audio"; + +bool MacroActionPlayAudio::_registered = MacroActionFactory::Register( + MacroActionPlayAudio::id, + {MacroActionPlayAudio::Create, MacroActionPlayAudioEdit::Create, + "AdvSceneSwitcher.action.playAudio"}); + +static void deactivatePlayback(obs_source_t *source, bool wantsOutput) +{ + if (wantsOutput) { + obs_set_output_source(kPlaybackOutputChannel, nullptr); + } else { + obs_source_dec_active(source); + } +} + +static void waitForPlaybackToEnd(Macro *macro, obs_source_t *source, + int64_t maxMs = 0) +{ + using namespace std::chrono_literals; + + std::unique_lock lock(*GetMutex()); + SetMacroAbortWait(false); + + // The media source needs time to open and decode before reaching + // PLAYING state. Poll until it starts (or the macro is stopped). + while (!MacroWaitShouldAbort() && !MacroIsStopped(macro)) { + if (obs_source_media_get_state(source) == + OBS_MEDIA_STATE_PLAYING) { + break; + } + GetMacroWaitCV().wait_for(lock, 10ms); + } + + // Now wait for playback to end. Require two consecutive non-playing + // samples to avoid false positives on brief state transitions. + // If maxMs > 0, also stop once that many milliseconds have elapsed. + const auto playbackStart = std::chrono::steady_clock::now(); + static const int kStopThreshold = 2; + int stoppedCount = 0; + while (!MacroWaitShouldAbort() && !MacroIsStopped(macro)) { + if (maxMs > 0) { + const auto elapsed = + std::chrono::duration_cast< + std::chrono::milliseconds>( + std::chrono::steady_clock::now() - + playbackStart) + .count(); + if (elapsed >= maxMs) { + obs_source_media_stop(source); + break; + } + } + if (obs_source_media_get_state(source) != + OBS_MEDIA_STATE_PLAYING) { + if (++stoppedCount >= kStopThreshold) { + break; + } + } else { + stoppedCount = 0; + } + GetMacroWaitCV().wait_for(lock, 10ms); + } +} + +bool MacroActionPlayAudio::PerformAction() +{ + std::string path = _filePath; + if (path.empty()) { + return true; + } + + if (!QFileInfo::exists(QString::fromStdString(path))) { + blog(LOG_WARNING, "audio file not found: \"%s\"", path.c_str()); + return true; + } + + OBSDataAutoRelease settings = obs_data_create(); + obs_data_set_string(settings, "local_file", path.c_str()); + obs_data_set_bool(settings, "is_local_file", true); + obs_data_set_bool(settings, "looping", false); + // Disable automatic restart on activate so we control start explicitly. + obs_data_set_bool(settings, "restart_on_activate", false); + obs_data_set_bool(settings, "close_when_inactive", true); + + OBSSourceAutoRelease source = obs_source_create_private( + "ffmpeg_source", "advss_play_audio", settings); + + if (!source) { + blog(LOG_WARNING, + "Failed to create ffmpeg_source for audio playback of \"%s\"", + path.c_str()); + return true; + } + + const float vol = + DecibelToPercent(static_cast(_volumeDB.GetValue())); + obs_source_set_volume(source, vol); + obs_source_set_monitoring_type(source, _monitorType); + + // Fall back to monitor-only if all output tracks are deselected — + // there is no point routing through the output channel if the mixer + // mask would silence every track. + const bool wantsOutput = + (_monitorType != OBS_MONITORING_TYPE_MONITOR_ONLY) && + (_audioMixers != 0); + if (!wantsOutput && + _monitorType == OBS_MONITORING_TYPE_MONITOR_AND_OUTPUT) { + obs_source_set_monitoring_type( + source, OBS_MONITORING_TYPE_MONITOR_ONLY); + } + + if (wantsOutput) { + obs_source_set_audio_mixers(source, _audioMixers); + // Route through a private scene so we can position the scene + // item far off-screen. This keeps the item "visible" (so audio + // is still mixed) while ensuring its video never intersects the + // output frame. + OBSSceneAutoRelease audioScene = + obs_scene_create_private("advss_audio_scene"); + obs_sceneitem_t *item = obs_scene_add(audioScene, source); + if (item) { + struct vec2 pos = {-99999.0f, -99999.0f}; + obs_sceneitem_set_pos(item, &pos); + } + obs_set_output_source(kPlaybackOutputChannel, + obs_scene_get_source(audioScene)); + // audioScene released here; the output channel holds the + // remaining reference and keeps the scene alive. + } else { + obs_source_set_audio_mixers(source, 0); + obs_source_inc_active(source); + } + + if (_useStartOffset && _startOffset.Milliseconds() > 0) { + obs_source_media_set_time(source, _startOffset.Milliseconds()); + } + + obs_source_media_play_pause(source, false); + + const int64_t maxMs = _useDuration ? _playbackDuration.Milliseconds() + : 0; + + if (_waitForCompletion) { + SetMacroAbortWait(false); + waitForPlaybackToEnd(GetMacro(), source, maxMs); + deactivatePlayback(source, wantsOutput); + return true; + } + + // Keep the source alive in a background thread that cleans up + // once playback finishes. Grab an extra strong reference so the + // source survives beyond this stack frame. + auto rawSource = obs_source_get_ref(source); + auto macro = GetMacro(); + + std::thread cleanupThread([rawSource, wantsOutput, macro, maxMs]() { + waitForPlaybackToEnd(macro, rawSource, maxMs); + deactivatePlayback(rawSource, wantsOutput); + obs_source_release(rawSource); + }); + AddMacroHelperThread(macro, std::move(cleanupThread)); + + return true; +} + +void MacroActionPlayAudio::LogAction() const +{ + ablog(LOG_INFO, + "playing audio file \"%s\" at %.1f dB (monitoring type %d, wait %d)", + _filePath.UnresolvedValue().c_str(), + static_cast(_volumeDB.GetFixedValue()), _monitorType, + (int)_waitForCompletion); +} + +bool MacroActionPlayAudio::Save(obs_data_t *obj) const +{ + MacroAction::Save(obj); + _filePath.Save(obj, "filePath"); + _volumeDB.Save(obj, "volumeDB"); + obs_data_set_int(obj, "monitorType", _monitorType); + obs_data_set_int(obj, "audioMixers", _audioMixers); + obs_data_set_bool(obj, "useStartOffset", _useStartOffset); + _startOffset.Save(obj, "startOffset"); + obs_data_set_bool(obj, "useDuration", _useDuration); + _playbackDuration.Save(obj, "playbackDuration"); + obs_data_set_bool(obj, "waitForCompletion", _waitForCompletion); + return true; +} + +bool MacroActionPlayAudio::Load(obs_data_t *obj) +{ + MacroAction::Load(obj); + _filePath.Load(obj, "filePath"); + _volumeDB.Load(obj, "volumeDB"); + _monitorType = static_cast( + obs_data_get_int(obj, "monitorType")); + _audioMixers = + static_cast(obs_data_get_int(obj, "audioMixers")); + _useStartOffset = obs_data_get_bool(obj, "useStartOffset"); + _startOffset.Load(obj, "startOffset"); + _useDuration = obs_data_get_bool(obj, "useDuration"); + _playbackDuration.Load(obj, "playbackDuration"); + _waitForCompletion = obs_data_get_bool(obj, "waitForCompletion"); + return true; +} + +std::string MacroActionPlayAudio::GetShortDesc() const +{ + return _filePath.UnresolvedValue(); +} + +std::shared_ptr MacroActionPlayAudio::Create(Macro *m) +{ + return std::make_shared(m); +} + +std::shared_ptr MacroActionPlayAudio::Copy() const +{ + return std::make_shared(*this); +} + +void MacroActionPlayAudio::ResolveVariablesToFixedValues() +{ + _filePath.ResolveVariables(); + _volumeDB.ResolveVariables(); + _startOffset.ResolveVariables(); + _playbackDuration.ResolveVariables(); +} + +MacroActionPlayAudioEdit::MacroActionPlayAudioEdit( + QWidget *parent, std::shared_ptr entryData) + : QWidget(parent), + _filePath(new FileSelection( + FileSelection::Type::READ, this, + obs_module_text( + "AdvSceneSwitcher.action.playAudio.file.browse"))), + _volumeDB(new VariableDoubleSpinBox), + _monitorTypes(new QComboBox), + _tracksContainer(new QWidget), + _useStartOffset(new QCheckBox(this)), + _startOffset(new DurationSelection(this, true, 0.0)), + _useDuration(new QCheckBox(this)), + _playbackDuration(new DurationSelection(this, true, 0.0)), + _waitForCompletion(new QCheckBox( + obs_module_text("AdvSceneSwitcher.action.playAudio.wait"))) +{ + _volumeDB->setMinimum(-100.0); + _volumeDB->setMaximum(0.0); + _volumeDB->setSuffix("dB"); + _volumeDB->setSpecialValueText("-inf"); + + if (obs_audio_monitoring_available()) { + PopulateMonitorTypeSelection(_monitorTypes); + } else { + _monitorTypes->addItem(obs_module_text( + "AdvSceneSwitcher.action.playAudio.monitorUnavailable")); + _monitorTypes->setEnabled(false); + } + + for (int i = 0; i < 6; ++i) { + _tracks[i] = new QCheckBox(QString::number(i + 1)); + QWidget::connect(_tracks[i], SIGNAL(stateChanged(int)), this, + SLOT(TrackChanged())); + } + + QWidget::connect(_filePath, SIGNAL(PathChanged(const QString &)), this, + SLOT(FilePathChanged(const QString &))); + QWidget::connect( + _volumeDB, + SIGNAL(NumberVariableChanged(const NumberVariable &)), + this, SLOT(VolumeDBChanged(const NumberVariable &))); + QWidget::connect(_monitorTypes, SIGNAL(currentIndexChanged(int)), this, + SLOT(MonitorTypeChanged(int))); + QWidget::connect(_useStartOffset, SIGNAL(stateChanged(int)), this, + SLOT(UseStartOffsetChanged(int))); + QWidget::connect(_useDuration, SIGNAL(stateChanged(int)), this, + SLOT(UseDurationChanged(int))); + QWidget::connect(_startOffset, + SIGNAL(DurationChanged(const Duration &)), this, + SLOT(StartOffsetChanged(const Duration &))); + QWidget::connect(_playbackDuration, + SIGNAL(DurationChanged(const Duration &)), this, + SLOT(PlaybackDurationChanged(const Duration &))); + QWidget::connect(_waitForCompletion, SIGNAL(stateChanged(int)), this, + SLOT(WaitChanged(int))); + + auto tracksLayout = new QHBoxLayout; + tracksLayout->setContentsMargins(0, 0, 0, 0); + tracksLayout->addWidget(new QLabel( + obs_module_text("AdvSceneSwitcher.action.playAudio.tracks"))); + for (auto *cb : _tracks) { + tracksLayout->addWidget(cb); + } + tracksLayout->addStretch(); + _tracksContainer->setLayout(tracksLayout); + + auto volumeLayout = new QHBoxLayout; + PlaceWidgets(obs_module_text( + "AdvSceneSwitcher.action.playAudio.layout.volume"), + volumeLayout, {{"{{volumeDB}}", _volumeDB}}); + auto monitorLayout = new QHBoxLayout; + PlaceWidgets( + obs_module_text( + "AdvSceneSwitcher.action.playAudio.layout.monitor"), + monitorLayout, {{"{{monitorTypes}}", _monitorTypes}}); + auto startOffsetLayout = new QHBoxLayout; + PlaceWidgets( + obs_module_text( + "AdvSceneSwitcher.action.playAudio.layout.startOffset"), + startOffsetLayout, + {{"{{useStartOffset}}", _useStartOffset}, + {"{{startOffset}}", _startOffset}}); + auto playbackDurationLayout = new QHBoxLayout; + PlaceWidgets( + obs_module_text( + "AdvSceneSwitcher.action.playAudio.layout.playbackDuration"), + playbackDurationLayout, + {{"{{useDuration}}", _useDuration}, + {"{{playbackDuration}}", _playbackDuration}}); + + auto mainLayout = new QVBoxLayout; + mainLayout->addWidget(_filePath); + mainLayout->addLayout(volumeLayout); + mainLayout->addLayout(monitorLayout); + mainLayout->addWidget(_tracksContainer); + mainLayout->addLayout(startOffsetLayout); + mainLayout->addLayout(playbackDurationLayout); + mainLayout->addWidget(_waitForCompletion); + setLayout(mainLayout); + + _entryData = entryData; + UpdateEntryData(); + _loading = false; +} + +void MacroActionPlayAudioEdit::UpdateEntryData() +{ + if (!_entryData) { + return; + } + + _filePath->SetPath(_entryData->_filePath); + _volumeDB->SetValue(_entryData->_volumeDB); + _monitorTypes->setCurrentIndex( + static_cast(_entryData->_monitorType)); + for (int i = 0; i < 6; ++i) { + _tracks[i]->setChecked(_entryData->_audioMixers & (1u << i)); + } + _tracksContainer->setVisible(_entryData->_monitorType != + OBS_MONITORING_TYPE_MONITOR_ONLY); + _useStartOffset->setChecked(_entryData->_useStartOffset); + _startOffset->SetDuration(_entryData->_startOffset); + _startOffset->setEnabled(_entryData->_useStartOffset); + _useDuration->setChecked(_entryData->_useDuration); + _playbackDuration->SetDuration(_entryData->_playbackDuration); + _playbackDuration->setEnabled(_entryData->_useDuration); + _waitForCompletion->setChecked(_entryData->_waitForCompletion); +} + +void MacroActionPlayAudioEdit::FilePathChanged(const QString &path) +{ + GUARD_LOADING_AND_LOCK(); + _entryData->_filePath = path.toStdString(); + emit HeaderInfoChanged( + QString::fromStdString(_entryData->GetShortDesc())); +} + +void MacroActionPlayAudioEdit::VolumeDBChanged( + const NumberVariable &value) +{ + GUARD_LOADING_AND_LOCK(); + _entryData->_volumeDB = value; +} + +void MacroActionPlayAudioEdit::MonitorTypeChanged(int value) +{ + GUARD_LOADING_AND_LOCK(); + _entryData->_monitorType = static_cast(value); + _tracksContainer->setVisible(value != OBS_MONITORING_TYPE_MONITOR_ONLY); +} + +void MacroActionPlayAudioEdit::TrackChanged() +{ + GUARD_LOADING_AND_LOCK(); + uint32_t mixers = 0; + for (int i = 0; i < 6; ++i) { + if (_tracks[i]->isChecked()) { + mixers |= (1u << i); + } + } + _entryData->_audioMixers = mixers; +} + +void MacroActionPlayAudioEdit::UseStartOffsetChanged(int value) +{ + GUARD_LOADING_AND_LOCK(); + _entryData->_useStartOffset = value; + _startOffset->setEnabled(_entryData->_useStartOffset); +} + +void MacroActionPlayAudioEdit::StartOffsetChanged(const Duration &value) +{ + GUARD_LOADING_AND_LOCK(); + _entryData->_startOffset = value; +} + +void MacroActionPlayAudioEdit::UseDurationChanged(int value) +{ + GUARD_LOADING_AND_LOCK(); + _entryData->_useDuration = value; + _playbackDuration->setEnabled(_entryData->_useDuration); +} + +void MacroActionPlayAudioEdit::PlaybackDurationChanged(const Duration &value) +{ + GUARD_LOADING_AND_LOCK(); + _entryData->_playbackDuration = value; +} + +void MacroActionPlayAudioEdit::WaitChanged(int value) +{ + GUARD_LOADING_AND_LOCK(); + _entryData->_waitForCompletion = value; +} + +} // namespace advss diff --git a/plugins/base/macro-action-play-audio.hpp b/plugins/base/macro-action-play-audio.hpp new file mode 100644 index 00000000..3ac91e86 --- /dev/null +++ b/plugins/base/macro-action-play-audio.hpp @@ -0,0 +1,91 @@ +#pragma once +#include "macro-action-edit.hpp" +#include "duration-control.hpp" +#include "file-selection.hpp" +#include "variable-spinbox.hpp" + +#include + +#include + +#include +#include +#include + +namespace advss { + +class MacroActionPlayAudio : public MacroAction { +public: + MacroActionPlayAudio(Macro *m) : MacroAction(m) {} + bool PerformAction(); + void LogAction() const; + bool Save(obs_data_t *obj) const; + bool Load(obs_data_t *obj); + std::string GetShortDesc() const; + std::string GetId() const { return id; } + static std::shared_ptr Create(Macro *m); + std::shared_ptr Copy() const; + void ResolveVariablesToFixedValues(); + + StringVariable _filePath = + obs_module_text("AdvSceneSwitcher.enterPath"); + DoubleVariable _volumeDB = 0.0; + obs_monitoring_type _monitorType = OBS_MONITORING_TYPE_MONITOR_ONLY; + uint32_t _audioMixers = 0x3F; // tracks 1-6, all on by default + bool _useStartOffset = false; + Duration _startOffset; + bool _useDuration = false; + Duration _playbackDuration; + bool _waitForCompletion = false; + + static bool _registered; + static const std::string id; +}; + +class MacroActionPlayAudioEdit : public QWidget { + Q_OBJECT + +public: + MacroActionPlayAudioEdit( + QWidget *parent, + std::shared_ptr entryData = nullptr); + void UpdateEntryData(); + static QWidget *Create(QWidget *parent, + std::shared_ptr action) + { + return new MacroActionPlayAudioEdit( + parent, std::dynamic_pointer_cast( + action)); + } + +private slots: + void FilePathChanged(const QString &path); + void VolumeDBChanged(const NumberVariable &value); + void MonitorTypeChanged(int value); + void TrackChanged(); + void UseStartOffsetChanged(int); + void StartOffsetChanged(const Duration &); + void UseDurationChanged(int); + void PlaybackDurationChanged(const Duration &); + void WaitChanged(int value); + +signals: + void HeaderInfoChanged(const QString &); + +private: + FileSelection *_filePath; + VariableDoubleSpinBox *_volumeDB; + QComboBox *_monitorTypes; + QWidget *_tracksContainer; + std::array _tracks; + QCheckBox *_useStartOffset; + DurationSelection *_startOffset; + QCheckBox *_useDuration; + DurationSelection *_playbackDuration; + QCheckBox *_waitForCompletion; + + std::shared_ptr _entryData; + bool _loading = true; +}; + +} // namespace advss