From a240797b68bdb9718ce3f1f4662bb02e62820e8b Mon Sep 17 00:00:00 2001 From: pajlada Date: Sun, 26 Nov 2023 22:06:12 +0100 Subject: [PATCH] Add support for sound backends & some miniaudio changes (#4978) Miniaudio now runs everything in a separate audio thread - this uses boost::asio's io_context. Our miniaudio implementation is now also much simplified - it does not use its own resource manager or device. This might end up being stupid if sounds don't work after changing output device or locking or w/e I've made the sound controller into an interface, meaning we can support multiple sound backends in Chatterino. I've added a Null sound backend that disables all sound. A QMediaPlayer or QSoundEffect or Qt backend could be added. Miniaudio might idle & disable the device now too, not sure I've added some unrelated changes in the form of a new setting type, and a new setting page helper function for it, which will hopefully make adding new enum settings easier in the future. This setting stores its value as a string instead of an int, and uses magic_enum to convert between that string value and its enum value. --- CHANGELOG.md | 2 + mocks/include/mocks/EmptyApplication.hpp | 6 + src/Application.cpp | 39 ++- src/Application.hpp | 13 +- src/CMakeLists.txt | 7 +- src/common/ChatterinoSetting.hpp | 55 +++ .../notifications/NotificationController.cpp | 4 +- src/controllers/sound/ISoundController.hpp | 38 ++ src/controllers/sound/MiniaudioBackend.cpp | 299 ++++++++++++++++ ...undController.hpp => MiniaudioBackend.hpp} | 34 +- src/controllers/sound/NullBackend.cpp | 18 + src/controllers/sound/NullBackend.hpp | 26 ++ src/controllers/sound/SoundController.cpp | 331 ------------------ src/messages/SharedMessageBuilder.cpp | 4 +- src/singletons/Settings.hpp | 7 + src/widgets/settingspages/GeneralPage.cpp | 10 + src/widgets/settingspages/GeneralPageView.hpp | 45 +++ 17 files changed, 578 insertions(+), 360 deletions(-) create mode 100644 src/controllers/sound/ISoundController.hpp create mode 100644 src/controllers/sound/MiniaudioBackend.cpp rename src/controllers/sound/{SoundController.hpp => MiniaudioBackend.hpp} (72%) create mode 100644 src/controllers/sound/NullBackend.cpp create mode 100644 src/controllers/sound/NullBackend.hpp delete mode 100644 src/controllers/sound/SoundController.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a0858681..57fd2f216 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Minor: The `/usercard` command now accepts user ids. (#4934) - Minor: Add menu actions to reply directly to a message or the original thread root. (#4923) - Minor: The `/reply` command now replies to the latest message of the user. (#4919) +- Minor: All sound capabilities can now be disabled by setting your "Sound backend" setting to "Null" and restarting Chatterino. (#4978) - Bugfix: Fixed an issue where certain emojis did not send to Twitch chat correctly. (#4840) - Bugfix: Fixed capitalized channel names in log inclusion list not being logged. (#4848) - Bugfix: Trimmed custom streamlink paths on all platforms making sure you don't accidentally add spaces at the beginning or end of its path. (#4834) @@ -41,6 +42,7 @@ - Bugfix: Fixed lookahead/-behind not working in _Ignores_. (#4965) - Bugfix: Fixed Image Uploader accidentally deleting images with some hosts when link resolver was enabled. (#4971) - Bugfix: Fixed rare crash with Image Uploader when closing a split right after starting an upload. (#4971) +- Dev: Run miniaudio in a separate thread, and simplify it to not manage the device ourselves. There's a chance the simplification is a bad idea. (#4978) - Dev: Change clang-format from v14 to v16. (#4929) - Dev: Fixed UTF16 encoding of `modes` file for the installer. (#4791) - Dev: Temporarily disable High DPI scaling on Qt6 builds on Windows. (#4767) diff --git a/mocks/include/mocks/EmptyApplication.hpp b/mocks/include/mocks/EmptyApplication.hpp index 612aed435..87deafa8a 100644 --- a/mocks/include/mocks/EmptyApplication.hpp +++ b/mocks/include/mocks/EmptyApplication.hpp @@ -85,6 +85,12 @@ public: return nullptr; } + ISoundController *getSound() override + { + assert(!"getSound was called without being initialized"); + return nullptr; + } + ITwitchLiveController *getTwitchLiveController() override { return nullptr; diff --git a/src/Application.cpp b/src/Application.cpp index 84c940f68..0a2f8b4de 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -10,12 +10,14 @@ #include "controllers/hotkeys/HotkeyController.hpp" #include "controllers/ignores/IgnoreController.hpp" #include "controllers/notifications/NotificationController.hpp" +#include "controllers/sound/ISoundController.hpp" #include "providers/seventv/SeventvAPI.hpp" #include "singletons/ImageUploader.hpp" #ifdef CHATTERINO_HAVE_PLUGINS # include "controllers/plugins/PluginController.hpp" #endif -#include "controllers/sound/SoundController.hpp" +#include "controllers/sound/MiniaudioBackend.hpp" +#include "controllers/sound/NullBackend.hpp" #include "controllers/twitch/LiveController.hpp" #include "controllers/userdata/UserDataController.hpp" #include "debug/AssertInGuiThread.hpp" @@ -57,6 +59,34 @@ #include +namespace { + +using namespace chatterino; + +ISoundController *makeSoundController(Settings &settings) +{ + SoundBackend soundBackend = settings.soundBackend; + switch (soundBackend) + { + case SoundBackend::Miniaudio: { + return new MiniaudioBackend(); + } + break; + + case SoundBackend::Null: { + return new NullBackend(); + } + break; + + default: { + return new MiniaudioBackend(); + } + break; + } +} + +} // namespace + namespace chatterino { static std::atomic isAppInitialized{false}; @@ -92,7 +122,7 @@ Application::Application(Settings &_settings, Paths &_paths) , ffzBadges(&this->emplace()) , seventvBadges(&this->emplace()) , userData(&this->emplace()) - , sound(&this->emplace()) + , sound(&this->emplace(makeSoundController(_settings))) , twitchLiveController(&this->emplace()) #ifdef CHATTERINO_HAVE_PLUGINS , plugins(&this->emplace()) @@ -260,6 +290,11 @@ IUserDataController *Application::getUserData() return this->userData; } +ISoundController *Application::getSound() +{ + return this->sound; +} + ITwitchLiveController *Application::getTwitchLiveController() { return this->twitchLiveController; diff --git a/src/Application.hpp b/src/Application.hpp index 38f0092c9..5dec1e906 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -22,6 +22,7 @@ class HighlightController; class HotkeyController; class IUserDataController; class UserDataController; +class ISoundController; class SoundController; class ITwitchLiveController; class TwitchLiveController; @@ -67,6 +68,7 @@ public: virtual FfzBadges *getFfzBadges() = 0; virtual SeventvBadges *getSeventvBadges() = 0; virtual IUserDataController *getUserData() = 0; + virtual ISoundController *getSound() = 0; virtual ITwitchLiveController *getTwitchLiveController() = 0; virtual ImageUploader *getImageUploader() = 0; virtual SeventvAPI *getSeventvAPI() = 0; @@ -109,7 +111,7 @@ public: FfzBadges *const ffzBadges{}; SeventvBadges *const seventvBadges{}; UserDataController *const userData{}; - SoundController *const sound{}; + ISoundController *const sound{}; private: TwitchLiveController *const twitchLiveController{}; @@ -172,6 +174,7 @@ public: return this->seventvBadges; } IUserDataController *getUserData() override; + ISoundController *getSound() override; ITwitchLiveController *getTwitchLiveController() override; ImageUploader *getImageUploader() override { @@ -200,6 +203,14 @@ private: return *t; } + template ::value>> + T &emplace(T *t) + { + this->singletons_.push_back(std::unique_ptr(t)); + return *t; + } + NativeMessagingServer nmServer{}; }; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a7bb6dc5b..9b75d7e90 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -224,8 +224,11 @@ set(SOURCE_FILES controllers/plugins/LuaUtilities.cpp controllers/plugins/LuaUtilities.hpp - controllers/sound/SoundController.cpp - controllers/sound/SoundController.hpp + controllers/sound/ISoundController.hpp + controllers/sound/MiniaudioBackend.cpp + controllers/sound/MiniaudioBackend.hpp + controllers/sound/NullBackend.cpp + controllers/sound/NullBackend.hpp controllers/twitch/LiveController.cpp controllers/twitch/LiveController.hpp diff --git a/src/common/ChatterinoSetting.hpp b/src/common/ChatterinoSetting.hpp index 4245626e0..6c9d8ec47 100644 --- a/src/common/ChatterinoSetting.hpp +++ b/src/common/ChatterinoSetting.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -85,4 +86,58 @@ public: } }; +/** + * Setters in this class allow for bad values, it's only the enum-specific getters that are protected. + * If you get a QString from this setting, it will be the raw value from the settings file. + * Use the explicit Enum conversions or getEnum to get a typed check with a default + **/ +template +class EnumStringSetting : public pajlada::Settings::Setting +{ +public: + EnumStringSetting(const std::string &path, const Enum &defaultValue_) + : pajlada::Settings::Setting(path) + , defaultValue(defaultValue_) + { + _registerSetting(this->getData()); + } + + template + EnumStringSetting &operator=(Enum newValue) + { + std::string enumName(magic_enum::enum_name(newValue)); + auto qEnumName = QString::fromStdString(enumName); + + this->setValue(qEnumName.toLower()); + + return *this; + } + + EnumStringSetting &operator=(QString newValue) + { + this->setValue(newValue.toLower()); + + return *this; + } + + operator Enum() + { + return this->getEnum(); + } + + Enum getEnum() + { + return magic_enum::enum_cast(this->getValue().toStdString(), + magic_enum::case_insensitive) + .value_or(this->defaultValue); + } + + Enum defaultValue; + + using pajlada::Settings::Setting::operator==; + using pajlada::Settings::Setting::operator!=; + + using pajlada::Settings::Setting::operator QString; +}; + } // namespace chatterino diff --git a/src/controllers/notifications/NotificationController.cpp b/src/controllers/notifications/NotificationController.cpp index e14d23e06..4866bf987 100644 --- a/src/controllers/notifications/NotificationController.cpp +++ b/src/controllers/notifications/NotificationController.cpp @@ -3,7 +3,7 @@ #include "Application.hpp" #include "common/QLogging.hpp" #include "controllers/notifications/NotificationModel.hpp" -#include "controllers/sound/SoundController.hpp" +#include "controllers/sound/ISoundController.hpp" #include "messages/Message.hpp" #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchIrcServer.hpp" @@ -105,7 +105,7 @@ void NotificationController::playSound() getSettings()->notificationPathSound.getValue()) : QUrl("qrc:/sounds/ping2.wav"); - getApp()->sound->play(highlightSoundUrl); + getIApp()->getSound()->play(highlightSoundUrl); } NotificationModel *NotificationController::createModel(QObject *parent, diff --git a/src/controllers/sound/ISoundController.hpp b/src/controllers/sound/ISoundController.hpp new file mode 100644 index 000000000..ebf7e3425 --- /dev/null +++ b/src/controllers/sound/ISoundController.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include "common/Singleton.hpp" + +#include + +namespace chatterino { + +class Settings; +class Paths; + +enum class SoundBackend { + Miniaudio, + Null, +}; + +/** + * @brief Handles sound loading & playback + **/ +class ISoundController : public Singleton +{ +public: + ISoundController() = default; + ~ISoundController() override = default; + ISoundController(const ISoundController &) = delete; + ISoundController(ISoundController &&) = delete; + ISoundController &operator=(const ISoundController &) = delete; + ISoundController &operator=(ISoundController &&) = delete; + + // Play a sound from the given url + // If the url points to something that isn't a local file, it will play + // the default sound initialized in the initialize method + // + // This function should not block + virtual void play(const QUrl &sound) = 0; +}; + +} // namespace chatterino diff --git a/src/controllers/sound/MiniaudioBackend.cpp b/src/controllers/sound/MiniaudioBackend.cpp new file mode 100644 index 000000000..288dd5085 --- /dev/null +++ b/src/controllers/sound/MiniaudioBackend.cpp @@ -0,0 +1,299 @@ +#include "controllers/sound/MiniaudioBackend.hpp" + +#include "Application.hpp" +#include "common/QLogging.hpp" +#include "debug/Benchmark.hpp" +#include "singletons/Paths.hpp" +#include "singletons/Settings.hpp" +#include "singletons/WindowManager.hpp" +#include "widgets/Window.hpp" + +#include + +#define MINIAUDIO_IMPLEMENTATION +#include +#include + +#include +#include + +namespace { + +using namespace chatterino; + +// The duration after which a sound is played we should try to stop the sound engine, hopefully +// returning the handle to idle letting the computer or monitors sleep +constexpr const auto STOP_AFTER_DURATION = std::chrono::seconds(30); + +void miniaudioLogCallback(void *userData, ma_uint32 level, const char *pMessage) +{ + (void)userData; + + QString message{pMessage}; + + switch (level) + { + case MA_LOG_LEVEL_DEBUG: { + qCDebug(chatterinoSound).noquote() + << "ma debug: " << message.trimmed(); + } + break; + case MA_LOG_LEVEL_INFO: { + qCDebug(chatterinoSound).noquote() + << "ma info: " << message.trimmed(); + } + break; + case MA_LOG_LEVEL_WARNING: { + qCWarning(chatterinoSound).noquote() + << "ma warning:" << message.trimmed(); + } + break; + case MA_LOG_LEVEL_ERROR: { + qCWarning(chatterinoSound).noquote() + << "ma error: " << message.trimmed(); + } + break; + default: { + qCWarning(chatterinoSound).noquote() + << "ma unknown:" << message.trimmed(); + } + break; + } +} + +} // namespace + +namespace chatterino { + +// NUM_SOUNDS specifies how many simultaneous default ping sounds & decoders to create +constexpr const auto NUM_SOUNDS = 4; + +void MiniaudioBackend::initialize(Settings &settings, Paths &paths) +{ + (void)(settings); + (void)(paths); + + boost::asio::post(this->ioContext, [this] { + ma_result result{}; + + // We are leaking this log object on purpose + auto *logger = new ma_log; + + result = ma_log_init(nullptr, logger); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) + << "Error initializing logger:" << result; + return; + } + + result = ma_log_register_callback( + logger, ma_log_callback_init(miniaudioLogCallback, nullptr)); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) + << "Error registering logger callback:" << result; + return; + } + + auto contextConfig = ma_context_config_init(); + contextConfig.pLog = logger; + + /// Initialize context + result = + ma_context_init(nullptr, 0, &contextConfig, this->context.get()); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) + << "Error initializing context:" << result; + return; + } + + /// Load default sound + QFile defaultPingFile(":/sounds/ping2.wav"); + if (!defaultPingFile.open(QIODevice::ReadOnly)) + { + qCWarning(chatterinoSound) << "Error loading default ping sound"; + return; + } + this->defaultPingData = defaultPingFile.readAll(); + + /// Initialize engine + auto engineConfig = ma_engine_config_init(); + engineConfig.pContext = this->context.get(); + engineConfig.noAutoStart = MA_TRUE; + + result = ma_engine_init(&engineConfig, this->engine.get()); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) + << "Error initializing engine:" << result; + return; + } + + /// Initialize default ping sounds + { + // TODO: Can we optimize this? + BenchmarkGuard b("init sounds"); + + ma_uint32 soundFlags = 0; + // Decode the sound during loading instead of during playback + soundFlags |= MA_SOUND_FLAG_DECODE; + // Disable pitch control (we don't use it, so this saves some performance) + soundFlags |= MA_SOUND_FLAG_NO_PITCH; + // Disable spatialization control, this brings the volume up to "normal levels" + soundFlags |= MA_SOUND_FLAG_NO_SPATIALIZATION; + + auto decoderConfig = + ma_decoder_config_init(ma_format_f32, 0, 48000); + // This must match the encoding format of our default ping sound + decoderConfig.encodingFormat = ma_encoding_format_wav; + + for (auto i = 0; i < NUM_SOUNDS; ++i) + { + auto dec = std::make_unique(); + auto snd = std::make_unique(); + + result = ma_decoder_init_memory( + (void *)this->defaultPingData.data(), + this->defaultPingData.size() * sizeof(char), &decoderConfig, + dec.get()); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) << "Error initializing default " + "ping decoder from memory:" + << result; + return; + } + + result = ma_sound_init_from_data_source(this->engine.get(), + dec.get(), soundFlags, + nullptr, snd.get()); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) + << "Error initializing default sound from data source:" + << result; + return; + } + + this->defaultPingDecoders.emplace_back(std::move(dec)); + this->defaultPingSounds.emplace_back(std::move(snd)); + } + } + + qCInfo(chatterinoSound) << "miniaudio sound system initialized"; + + this->initialized = true; + }); + + this->audioThread = std::make_unique([this] { + this->ioContext.run(); + }); +} + +MiniaudioBackend::MiniaudioBackend() + : context(std::make_unique()) + , engine(std::make_unique()) + , workGuard(boost::asio::make_work_guard(this->ioContext)) + , sleepTimer(this->ioContext) +{ + qCInfo(chatterinoSound) << "Initializing miniaudio sound backend"; +} + +MiniaudioBackend::~MiniaudioBackend() +{ + // NOTE: This destructor is never called because the `runGui` function calls _exit before that happens + // I have manually called the destructor prior to _exit being called to ensure this logic is sound + + boost::asio::post(this->ioContext, [this] { + for (const auto &snd : this->defaultPingSounds) + { + ma_sound_uninit(snd.get()); + } + for (const auto &dec : this->defaultPingDecoders) + { + ma_decoder_uninit(dec.get()); + } + + ma_engine_uninit(this->engine.get()); + ma_context_uninit(this->context.get()); + + this->workGuard.reset(); + }); + + if (this->audioThread->joinable()) + { + this->audioThread->join(); + } + else + { + qCWarning(chatterinoSound) << "Audio thread not joinable"; + } +} + +void MiniaudioBackend::play(const QUrl &sound) +{ + boost::asio::post(this->ioContext, [this, sound] { + static size_t i = 0; + + this->tgPlay.guard(); + + if (!this->initialized) + { + qCWarning(chatterinoSound) << "Can't play sound, sound controller " + "didn't initialize correctly"; + return; + } + + auto result = ma_engine_start(this->engine.get()); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) << "Error starting engine " << result; + return; + } + + if (sound.isLocalFile()) + { + auto soundPath = sound.toLocalFile(); + auto result = ma_engine_play_sound(this->engine.get(), + qPrintable(soundPath), nullptr); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) << "Failed to play sound" << sound + << soundPath << ":" << result; + } + + return; + } + + // Play default sound, loaded from our resources in the constructor + auto &snd = this->defaultPingSounds[++i % NUM_SOUNDS]; + ma_sound_seek_to_pcm_frame(snd.get(), 0); + result = ma_sound_start(snd.get()); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) + << "Failed to play default ping" << result; + } + + this->sleepTimer.expires_from_now(STOP_AFTER_DURATION); + this->sleepTimer.async_wait([this](const auto &ec) { + if (ec) + { + // Timer was most likely cancelled + return; + } + + auto result = ma_engine_stop(this->engine.get()); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) + << "Error stopping miniaudio engine " << result; + return; + } + }); + }); +} + +} // namespace chatterino diff --git a/src/controllers/sound/SoundController.hpp b/src/controllers/sound/MiniaudioBackend.hpp similarity index 72% rename from src/controllers/sound/SoundController.hpp rename to src/controllers/sound/MiniaudioBackend.hpp index fbd3ac87f..ef730a06f 100644 --- a/src/controllers/sound/SoundController.hpp +++ b/src/controllers/sound/MiniaudioBackend.hpp @@ -1,8 +1,9 @@ #pragma once -#include "common/Singleton.hpp" +#include "controllers/sound/ISoundController.hpp" #include "util/ThreadGuard.hpp" +#include #include #include #include @@ -19,33 +20,25 @@ struct ma_decoder; namespace chatterino { -class Settings; -class Paths; - /** * @brief Handles sound loading & playback **/ -class SoundController : public Singleton +class MiniaudioBackend : public ISoundController { - SoundController(); - void initialize(Settings &settings, Paths &paths) override; public: - ~SoundController() override; + MiniaudioBackend(); + ~MiniaudioBackend() override; // Play a sound from the given url // If the url points to something that isn't a local file, it will play // the default sound initialized in the initialize method - void play(const QUrl &sound); + void play(const QUrl &sound) final; private: // Used for selecting & initializing an appropriate sound backend std::unique_ptr context; - // Used for storing & reusing sounds to be played - std::unique_ptr resourceManager; - // The sound device we're playing sound into - std::unique_ptr device{nullptr}; // The engine is a high-level API for playing sounds from paths in a simple & efficient-enough manner std::unique_ptr engine; @@ -62,14 +55,15 @@ private: // Ensures play is only ever called from the same thread ThreadGuard tgPlay; - bool initialized{false}; + std::chrono::system_clock::time_point lastSoundPlay; - // Recreates the sound device - // This is used during initialization, and can also be used if the device - // needs to be recreated during playback - // - // Returns false on failure - bool recreateDevice(); + boost::asio::io_context ioContext{1}; + boost::asio::executor_work_guard + workGuard; + std::unique_ptr audioThread; + boost::asio::steady_timer sleepTimer; + + bool initialized{false}; friend class Application; }; diff --git a/src/controllers/sound/NullBackend.cpp b/src/controllers/sound/NullBackend.cpp new file mode 100644 index 000000000..7f1797e2e --- /dev/null +++ b/src/controllers/sound/NullBackend.cpp @@ -0,0 +1,18 @@ +#include "controllers/sound/NullBackend.hpp" + +#include "common/QLogging.hpp" + +namespace chatterino { + +NullBackend::NullBackend() +{ + qCInfo(chatterinoSound) << "Initializing null sound backend"; +} + +void NullBackend::play(const QUrl &sound) +{ + // Do nothing + qCDebug(chatterinoSound) << "null backend asked to play" << sound; +} + +} // namespace chatterino diff --git a/src/controllers/sound/NullBackend.hpp b/src/controllers/sound/NullBackend.hpp new file mode 100644 index 000000000..421deff07 --- /dev/null +++ b/src/controllers/sound/NullBackend.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "controllers/sound/ISoundController.hpp" + +namespace chatterino { + +/** + * @brief This sound backend does nothing + **/ +class NullBackend final : public ISoundController +{ +public: + NullBackend(); + ~NullBackend() override = default; + NullBackend(const NullBackend &) = delete; + NullBackend(NullBackend &&) = delete; + NullBackend &operator=(const NullBackend &) = delete; + NullBackend &operator=(NullBackend &&) = delete; + + // Play a sound from the given url + // If the url points to something that isn't a local file, it will play + // the default sound initialized in the initialize method + void play(const QUrl &sound) final; +}; + +} // namespace chatterino diff --git a/src/controllers/sound/SoundController.cpp b/src/controllers/sound/SoundController.cpp deleted file mode 100644 index 5a527d0e1..000000000 --- a/src/controllers/sound/SoundController.cpp +++ /dev/null @@ -1,331 +0,0 @@ -#include "controllers/sound/SoundController.hpp" - -#include "common/QLogging.hpp" -#include "debug/Benchmark.hpp" -#include "singletons/Paths.hpp" -#include "singletons/Settings.hpp" - -#define MINIAUDIO_IMPLEMENTATION -#include -#include - -#include -#include - -namespace { - -using namespace chatterino; - -void miniaudioLogCallback(void *userData, ma_uint32 level, const char *pMessage) -{ - (void)userData; - - QString message{pMessage}; - - switch (level) - { - case MA_LOG_LEVEL_DEBUG: { - qCDebug(chatterinoSound).noquote() - << "ma debug: " << message.trimmed(); - } - break; - case MA_LOG_LEVEL_INFO: { - qCDebug(chatterinoSound).noquote() - << "ma info: " << message.trimmed(); - } - break; - case MA_LOG_LEVEL_WARNING: { - qCWarning(chatterinoSound).noquote() - << "ma warning:" << message.trimmed(); - } - break; - case MA_LOG_LEVEL_ERROR: { - qCWarning(chatterinoSound).noquote() - << "ma error: " << message.trimmed(); - } - break; - default: { - qCWarning(chatterinoSound).noquote() - << "ma unknown:" << message.trimmed(); - } - break; - } -} - -} // namespace - -namespace chatterino { - -// NUM_SOUNDS specifies how many simultaneous default ping sounds & decoders to create -constexpr const auto NUM_SOUNDS = 4; - -SoundController::SoundController() - : context(std::make_unique()) - , resourceManager(std::make_unique()) - , engine(std::make_unique()) -{ -} - -void SoundController::initialize(Settings &settings, Paths &paths) -{ - (void)(settings); - (void)(paths); - - ma_result result{}; - - // We are leaking this log object on purpose - auto *logger = new ma_log; - - result = ma_log_init(nullptr, logger); - if (result != MA_SUCCESS) - { - qCWarning(chatterinoSound) << "Error initializing logger:" << result; - return; - } - - result = ma_log_register_callback( - logger, ma_log_callback_init(miniaudioLogCallback, nullptr)); - if (result != MA_SUCCESS) - { - qCWarning(chatterinoSound) - << "Error registering logger callback:" << result; - return; - } - - auto contextConfig = ma_context_config_init(); - contextConfig.pLog = logger; - - /// Initialize context - result = ma_context_init(nullptr, 0, &contextConfig, this->context.get()); - if (result != MA_SUCCESS) - { - qCWarning(chatterinoSound) << "Error initializing context:" << result; - return; - } - - /// Initialize resource manager - auto resourceManagerConfig = ma_resource_manager_config_init(); - resourceManagerConfig.decodedFormat = ma_format_f32; - // Use native channel count - resourceManagerConfig.decodedChannels = 0; - resourceManagerConfig.decodedSampleRate = 48000; - - result = ma_resource_manager_init(&resourceManagerConfig, - this->resourceManager.get()); - if (result != MA_SUCCESS) - { - qCWarning(chatterinoSound) - << "Error initializing resource manager:" << result; - return; - } - - /// Load default sound - QFile defaultPingFile(":/sounds/ping2.wav"); - if (!defaultPingFile.open(QIODevice::ReadOnly)) - { - qCWarning(chatterinoSound) << "Error loading default ping sound"; - return; - } - this->defaultPingData = defaultPingFile.readAll(); - - /// Initialize a sound device - if (!this->recreateDevice()) - { - qCWarning(chatterinoSound) << "Failed to create the initial device"; - return; - } - - /// Initialize engine - auto engineConfig = ma_engine_config_init(); - engineConfig.pResourceManager = this->resourceManager.get(); - engineConfig.pDevice = this->device.get(); - engineConfig.pContext = this->context.get(); - - result = ma_engine_init(&engineConfig, this->engine.get()); - if (result != MA_SUCCESS) - { - qCWarning(chatterinoSound) << "Error initializing engine:" << result; - return; - } - - /// Initialize default ping sounds - { - // TODO: Can we optimize this? - BenchmarkGuard b("init sounds"); - - ma_uint32 soundFlags = 0; - // Decode the sound during loading instead of during playback - soundFlags |= MA_SOUND_FLAG_DECODE; - // Disable pitch control (we don't use it, so this saves some performance) - soundFlags |= MA_SOUND_FLAG_NO_PITCH; - // Disable spatialization control, this brings the volume up to "normal levels" - soundFlags |= MA_SOUND_FLAG_NO_SPATIALIZATION; - - auto decoderConfig = ma_decoder_config_init(ma_format_f32, 0, 48000); - // This must match the encoding format of our default ping sound - decoderConfig.encodingFormat = ma_encoding_format_wav; - - for (auto i = 0; i < NUM_SOUNDS; ++i) - { - auto dec = std::make_unique(); - auto snd = std::make_unique(); - - result = ma_decoder_init_memory( - (void *)this->defaultPingData.data(), - this->defaultPingData.size() * sizeof(char), &decoderConfig, - dec.get()); - if (result != MA_SUCCESS) - { - qCWarning(chatterinoSound) - << "Error initializing default ping decoder from memory:" - << result; - return; - } - - result = ma_sound_init_from_data_source( - this->engine.get(), dec.get(), soundFlags, nullptr, snd.get()); - if (result != MA_SUCCESS) - { - qCWarning(chatterinoSound) - << "Error initializing default sound from data source:" - << result; - return; - } - - this->defaultPingDecoders.emplace_back(std::move(dec)); - this->defaultPingSounds.emplace_back(std::move(snd)); - } - } - - qCInfo(chatterinoSound) << "miniaudio sound system initialized"; - - this->initialized = true; -} - -SoundController::~SoundController() -{ - // NOTE: This destructor is never called because the `runGui` function calls _exit before that happens - // I have manually called the destructor prior to _exit being called to ensure this logic is sound - - for (const auto &snd : this->defaultPingSounds) - { - ma_sound_uninit(snd.get()); - } - for (const auto &dec : this->defaultPingDecoders) - { - ma_decoder_uninit(dec.get()); - } - - ma_engine_uninit(this->engine.get()); - if (this->device) - { - ma_device_uninit(this->device.get()); - this->device.reset(); - } - ma_resource_manager_uninit(this->resourceManager.get()); - ma_context_uninit(this->context.get()); -} - -void SoundController::play(const QUrl &sound) -{ - static size_t i = 0; - - this->tgPlay.guard(); - - if (!this->initialized) - { - qCWarning(chatterinoSound) << "Can't play sound, sound controller " - "didn't initialize correctly"; - return; - } - - auto deviceState = ma_device_get_state(this->device.get()); - - if (deviceState != ma_device_state_started) - { - // Device state is not as it should be, try to restart it - qCWarning(chatterinoSound) - << "Sound device was not started, attempting to restart it" - << deviceState; - - auto result = ma_device_start(this->device.get()); - if (result != MA_SUCCESS) - { - qCWarning(chatterinoSound) - << "Failed to start the sound device" << result; - - if (!this->recreateDevice()) - { - qCWarning(chatterinoSound) << "Failed to recreate device"; - return; - } - } - - qCInfo(chatterinoSound) << "Successfully restarted the sound device"; - } - - if (sound.isLocalFile()) - { - auto soundPath = sound.toLocalFile(); - auto result = ma_engine_play_sound(this->engine.get(), - qPrintable(soundPath), nullptr); - if (result != MA_SUCCESS) - { - qCWarning(chatterinoSound) << "Failed to play sound" << sound - << soundPath << ":" << result; - } - - return; - } - - // Play default sound, loaded from our resources in the constructor - auto &snd = this->defaultPingSounds[++i % NUM_SOUNDS]; - ma_sound_seek_to_pcm_frame(snd.get(), 0); - auto result = ma_sound_start(snd.get()); - if (result != MA_SUCCESS) - { - qCWarning(chatterinoSound) << "Failed to play default ping" << result; - } -} - -bool SoundController::recreateDevice() -{ - ma_result result{}; - - if (this->device) - { - // Release the previous device first - qCDebug(chatterinoSound) << "Uniniting previously created device"; - ma_device_uninit(this->device.get()); - } - - this->device = std::make_unique(); - - auto deviceConfig = ma_device_config_init(ma_device_type_playback); - deviceConfig.playback.pDeviceID = nullptr; - deviceConfig.playback.format = this->resourceManager->config.decodedFormat; - deviceConfig.playback.channels = 0; - deviceConfig.pulse.pStreamNamePlayback = "Chatterino MA"; - deviceConfig.sampleRate = this->resourceManager->config.decodedSampleRate; - deviceConfig.dataCallback = ma_engine_data_callback_internal; - deviceConfig.pUserData = this->engine.get(); - - result = - ma_device_init(this->context.get(), &deviceConfig, this->device.get()); - if (result != MA_SUCCESS) - { - qCWarning(chatterinoSound) << "Error initializing device:" << result; - return false; - } - - result = ma_device_start(this->device.get()); - if (result != MA_SUCCESS) - { - qCWarning(chatterinoSound) << "Error starting device:" << result; - return false; - } - - return true; -} - -} // namespace chatterino diff --git a/src/messages/SharedMessageBuilder.cpp b/src/messages/SharedMessageBuilder.cpp index 0231f2045..719ec1bed 100644 --- a/src/messages/SharedMessageBuilder.cpp +++ b/src/messages/SharedMessageBuilder.cpp @@ -6,7 +6,7 @@ #include "controllers/ignores/IgnoreController.hpp" #include "controllers/ignores/IgnorePhrase.hpp" #include "controllers/nicknames/Nickname.hpp" -#include "controllers/sound/SoundController.hpp" +#include "controllers/sound/ISoundController.hpp" #include "messages/Message.hpp" #include "messages/MessageElement.hpp" #include "providers/twitch/TwitchBadge.hpp" @@ -217,7 +217,7 @@ void SharedMessageBuilder::triggerHighlights() if (this->highlightSound_ && resolveFocus) { - getApp()->sound->play(this->highlightSoundUrl_); + getIApp()->getSound()->play(this->highlightSoundUrl_); } if (this->highlightAlert_) diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 4a9af1702..40d7be51e 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -12,6 +12,7 @@ #include "controllers/logging/ChannelLog.hpp" #include "controllers/moderationactions/ModerationAction.hpp" #include "controllers/nicknames/Nickname.hpp" +#include "controllers/sound/ISoundController.hpp" #include "singletons/Toasts.hpp" #include "util/RapidJsonSerializeQString.hpp" #include "util/StreamerMode.hpp" @@ -557,6 +558,12 @@ public: ChatterinoSetting> enabledPlugins = { "/plugins/enabledPlugins", {}}; + // Advanced + EnumStringSetting soundBackend = { + "/sound/backend", + SoundBackend::Miniaudio, + }; + private: ChatterinoSetting> highlightedMessagesSetting = {"/highlighting/highlights"}; diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index 08d30a630..48ecdb486 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -5,6 +5,7 @@ #include "common/Version.hpp" #include "controllers/hotkeys/HotkeyCategory.hpp" #include "controllers/hotkeys/HotkeyController.hpp" +#include "controllers/sound/ISoundController.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchIrcServer.hpp" #include "singletons/Fonts.hpp" @@ -20,6 +21,7 @@ #include "widgets/settingspages/GeneralPageView.hpp" #include "widgets/splits/SplitInput.hpp" +#include #include #include #include @@ -1134,6 +1136,14 @@ void GeneralPage::initLayout(GeneralPageView &layout) "Show a Send button next to each split input that can be " "clicked to send the message"); + auto *soundBackend = layout.addDropdownEnumClass( + "Sound backend (requires restart)", + magic_enum::enum_names(), s.soundBackend, + "Change this only if you're noticing issues with sound playback on " + "your system", + {}); + soundBackend->setMinimumWidth(soundBackend->minimumSizeHint().width()); + layout.addStretch(); // invisible element for width diff --git a/src/widgets/settingspages/GeneralPageView.hpp b/src/widgets/settingspages/GeneralPageView.hpp index 101b0b7b9..337855495 100644 --- a/src/widgets/settingspages/GeneralPageView.hpp +++ b/src/widgets/settingspages/GeneralPageView.hpp @@ -247,6 +247,51 @@ public: return combo; } + template + ComboBox *addDropdownEnumClass(const QString &text, + const std::array &items, + EnumStringSetting &setting, + QString toolTipText, + const QString &defaultValueText) + { + auto *combo = this->addDropdown(text, {}, std::move(toolTipText)); + + for (const auto &text : items) + { + combo->addItem(QString::fromStdString(std::string(text))); + } + + if (!defaultValueText.isEmpty()) + { + combo->setCurrentText(defaultValueText); + } + + setting.connect( + [&setting, combo](const QString &value) { + auto enumValue = + magic_enum::enum_cast(value.toStdString(), + magic_enum::case_insensitive) + .value_or(setting.defaultValue); + + auto i = magic_enum::enum_integer(enumValue); + + combo->setCurrentIndex(i); + }, + this->managedConnections_); + + QObject::connect( + combo, &QComboBox::currentTextChanged, + [&setting](const auto &newText) { + // The setter for EnumStringSetting does not check that this value is valid + // Instead, it's up to the getters to make sure that the setting is legic - see the enum_cast above + // You could also use the settings `getEnum` function + setting = newText; + getApp()->windows->forceLayoutChannelViews(); + }); + + return combo; + } + DescriptionLabel *addDescription(const QString &text); void addSeperator();