diff --git a/.gitmodules b/.gitmodules index 19a31943c..f1a5f351a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -35,3 +35,6 @@ [submodule "lib/googletest"] path = lib/googletest url = https://github.com/google/googletest.git +[submodule "lib/miniaudio"] + path = lib/miniaudio + url = https://github.com/mackron/miniaudio.git diff --git a/CHANGELOG.md b/CHANGELOG.md index a000a900f..0db2ce31a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - Bugfix: Fixed the split "Search" menu action not opening the correct search window. (#4305) - Bugfix: Fixed an issue on Windows when opening links in incognito mode that contained forward slashes in hash (#4307) - Bugfix: Fixed an issue where beta versions wouldn't update to stable versions correctly. (#4329) +- Dev: Changed sound backend from Qt to miniaudio. (#4334) - Dev: Remove protocol from QApplication's Organization Domain (so changed from `https://www.chatterino.com` to `chatterino.com`). (#4256) - Dev: Ignore `WM_SHOWWINDOW` hide events, causing fewer attempted rescales. (#4198) - Dev: Migrated to C++ 20 (#4252, #4257) diff --git a/lib/miniaudio b/lib/miniaudio new file mode 160000 index 000000000..c153a9479 --- /dev/null +++ b/lib/miniaudio @@ -0,0 +1 @@ +Subproject commit c153a947919808419b0bf3f56b6f2ee606d6c5f4 diff --git a/resources/licenses/miniaudio.txt b/resources/licenses/miniaudio.txt new file mode 100644 index 000000000..a203fa843 --- /dev/null +++ b/resources/licenses/miniaudio.txt @@ -0,0 +1,16 @@ +Copyright 2020 David Reid + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/Application.cpp b/src/Application.cpp index 1d3207e75..7d02b3931 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -10,6 +10,7 @@ #include "controllers/hotkeys/HotkeyController.hpp" #include "controllers/ignores/IgnoreController.hpp" #include "controllers/notifications/NotificationController.hpp" +#include "controllers/sound/SoundController.hpp" #include "controllers/userdata/UserDataController.hpp" #include "debug/AssertInGuiThread.hpp" #include "messages/Message.hpp" @@ -45,6 +46,7 @@ #include "widgets/splits/Split.hpp" #include "widgets/Window.hpp" +#include #include #include @@ -82,6 +84,7 @@ Application::Application(Settings &_settings, Paths &_paths) , ffzBadges(&this->emplace()) , seventvBadges(&this->emplace()) , userData(&this->emplace()) + , sound(&this->emplace()) , logging(&this->emplace()) { this->instance = this; diff --git a/src/Application.hpp b/src/Application.hpp index 9620c4960..dada8d02a 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -19,6 +19,7 @@ class HighlightController; class HotkeyController; class IUserDataController; class UserDataController; +class SoundController; class Theme; class WindowManager; @@ -92,6 +93,7 @@ public: FfzBadges *const ffzBadges{}; SeventvBadges *const seventvBadges{}; UserDataController *const userData{}; + SoundController *const sound{}; /*[[deprecated]]*/ Logging *const logging{}; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 79210ab0b..4b89ebbfb 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -140,6 +140,9 @@ set(SOURCE_FILES controllers/userdata/UserDataController.hpp controllers/userdata/UserData.hpp + controllers/sound/SoundController.cpp + controllers/sound/SoundController.hpp + debug/Benchmark.cpp debug/Benchmark.hpp @@ -771,6 +774,17 @@ target_include_directories(${LIBRARY_PROJECT} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} # semver dependency https://github.com/Neargye/semver target_include_directories(${LIBRARY_PROJECT} PUBLIC ${CMAKE_SOURCE_DIR}/lib/semver/include) +# miniaudio dependency https://github.com/mackron/miniaudio +target_include_directories(${LIBRARY_PROJECT} PUBLIC ${CMAKE_SOURCE_DIR}/lib/miniaudio) + +if (UNIX) + if (CMAKE_DL_LIBS) + # libdl is a requirement for miniaudio on Linux + message(STATUS "Linking with CMake DL libs: '${CMAKE_DL_LIBS}'") + target_link_libraries(${LIBRARY_PROJECT} PUBLIC ${CMAKE_DL_LIBS}) + endif () +endif () + if (WinToast_FOUND) target_link_libraries(${LIBRARY_PROJECT} PUBLIC diff --git a/src/PrecompiledHeader.hpp b/src/PrecompiledHeader.hpp index b49cb431d..0f7ac7643 100644 --- a/src/PrecompiledHeader.hpp +++ b/src/PrecompiledHeader.hpp @@ -64,7 +64,6 @@ # include # include # include -# include # include # include # include diff --git a/src/common/QLogging.cpp b/src/common/QLogging.cpp index bd1474a13..8679e7a8d 100644 --- a/src/common/QLogging.cpp +++ b/src/common/QLogging.cpp @@ -39,6 +39,7 @@ Q_LOGGING_CATEGORY(chatterinoSettings, "chatterino.settings", logThreshold); Q_LOGGING_CATEGORY(chatterinoSeventv, "chatterino.seventv", logThreshold); Q_LOGGING_CATEGORY(chatterinoSeventvEventAPI, "chatterino.seventv.eventapi", logThreshold); +Q_LOGGING_CATEGORY(chatterinoSound, "chatterino.sound", logThreshold); Q_LOGGING_CATEGORY(chatterinoStreamerMode, "chatterino.streamermode", logThreshold); Q_LOGGING_CATEGORY(chatterinoStreamlink, "chatterino.streamlink", logThreshold); diff --git a/src/common/QLogging.hpp b/src/common/QLogging.hpp index c49c685a3..0aa50fae6 100644 --- a/src/common/QLogging.hpp +++ b/src/common/QLogging.hpp @@ -29,6 +29,7 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoRecentMessages); Q_DECLARE_LOGGING_CATEGORY(chatterinoSettings); Q_DECLARE_LOGGING_CATEGORY(chatterinoSeventv); Q_DECLARE_LOGGING_CATEGORY(chatterinoSeventvEventAPI); +Q_DECLARE_LOGGING_CATEGORY(chatterinoSound); Q_DECLARE_LOGGING_CATEGORY(chatterinoStreamerMode); Q_DECLARE_LOGGING_CATEGORY(chatterinoStreamlink); Q_DECLARE_LOGGING_CATEGORY(chatterinoTokenizer); diff --git a/src/controllers/notifications/NotificationController.cpp b/src/controllers/notifications/NotificationController.cpp index 2028c9839..69da63b01 100644 --- a/src/controllers/notifications/NotificationController.cpp +++ b/src/controllers/notifications/NotificationController.cpp @@ -5,6 +5,7 @@ #include "common/Outcome.hpp" #include "common/QLogging.hpp" #include "controllers/notifications/NotificationModel.hpp" +#include "controllers/sound/SoundController.hpp" #include "messages/Message.hpp" #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchIrcServer.hpp" @@ -21,7 +22,6 @@ #include #include -#include #include #include @@ -97,25 +97,13 @@ void NotificationController::removeChannelNotification( } void NotificationController::playSound() { - static auto player = new QMediaPlayer; - static QUrl currentPlayerUrl; - QUrl highlightSoundUrl = getSettings()->notificationCustomSound ? QUrl::fromLocalFile( getSettings()->notificationPathSound.getValue()) : QUrl("qrc:/sounds/ping2.wav"); - // Set media if the highlight sound url has changed, or if media is buffered - if (currentPlayerUrl != highlightSoundUrl || - player->mediaStatus() == QMediaPlayer::BufferedMedia) - { - player->setMedia(highlightSoundUrl); - - currentPlayerUrl = highlightSoundUrl; - } - - player->play(); + getApp()->sound->play(highlightSoundUrl); } NotificationModel *NotificationController::createModel(QObject *parent, diff --git a/src/controllers/sound/SoundController.cpp b/src/controllers/sound/SoundController.cpp new file mode 100644 index 000000000..22c71a90c --- /dev/null +++ b/src/controllers/sound/SoundController.cpp @@ -0,0 +1,216 @@ +#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 + +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()) + , device(std::make_unique()) + , engine(std::make_unique()) +{ +} + +void SoundController::initialize(Settings &settings, Paths &paths) +{ + (void)(settings); + (void)(paths); + + ma_result result{}; + + /// Initialize context + result = ma_context_init(nullptr, 0, nullptr, 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 + 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; + } + + result = ma_device_start(this->device.get()); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) << "Error starting device:" << result; + 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()); + ma_device_uninit(this->device.get()); + 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; + } + + 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; + } +} + +} // namespace chatterino diff --git a/src/controllers/sound/SoundController.hpp b/src/controllers/sound/SoundController.hpp new file mode 100644 index 000000000..5591b982f --- /dev/null +++ b/src/controllers/sound/SoundController.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include "common/Singleton.hpp" +#include "util/ThreadGuard.hpp" + +#include +#include +#include + +#include +#include + +struct ma_engine; +struct ma_device; +struct ma_resource_manager; +struct ma_context; +struct ma_sound; +struct ma_decoder; + +namespace chatterino { + +class Settings; +class Paths; + +/** + * @brief Handles sound loading & playback + **/ +class SoundController : public Singleton +{ + SoundController(); + + void initialize(Settings &settings, Paths &paths) override; + +public: + ~SoundController() 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); + +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; + // The engine is a high-level API for playing sounds from paths in a simple & efficient-enough manner + std::unique_ptr engine; + + // Stores the data of our default ping sounds + QByteArray defaultPingData; + // Stores N decoders for simultaneous default ping playback. + // We can't use the engine API for this as this requires direct access to a custom data_source + std::vector> defaultPingDecoders; + // Stores N sounds for simultaneous default ping playback + // We can't use the engine API for this as this requires direct access to a custom data_source + std::vector> defaultPingSounds; + + // Thread guard for the play method + // Ensures play is only ever called from the same thread + ThreadGuard tgPlay; + + bool initialized{false}; + + friend class Application; +}; + +} // namespace chatterino diff --git a/src/messages/SharedMessageBuilder.cpp b/src/messages/SharedMessageBuilder.cpp index 426ead827..9d0fda90c 100644 --- a/src/messages/SharedMessageBuilder.cpp +++ b/src/messages/SharedMessageBuilder.cpp @@ -6,6 +6,7 @@ #include "controllers/ignores/IgnoreController.hpp" #include "controllers/ignores/IgnorePhrase.hpp" #include "controllers/nicknames/Nickname.hpp" +#include "controllers/sound/SoundController.hpp" #include "messages/Message.hpp" #include "messages/MessageElement.hpp" #include "providers/twitch/TwitchBadge.hpp" @@ -16,34 +17,33 @@ #include "util/StreamerMode.hpp" #include -#include - -namespace chatterino { namespace { - /** - * Gets the default sound url if the user set one, - * or the chatterino default ping sound if no url is set. - */ - QUrl getFallbackHighlightSound() - { - QString path = getSettings()->pathHighlightSound; - bool fileExists = !path.isEmpty() && QFileInfo::exists(path) && - QFileInfo(path).isFile(); +using namespace chatterino; - if (fileExists) - { - return QUrl::fromLocalFile(path); - } - else - { - return QUrl("qrc:/sounds/ping2.wav"); - } +/** + * Gets the default sound url if the user set one, + * or the chatterino default ping sound if no url is set. + */ +QUrl getFallbackHighlightSound() +{ + QString path = getSettings()->pathHighlightSound; + bool fileExists = + !path.isEmpty() && QFileInfo::exists(path) && QFileInfo(path).isFile(); + + if (fileExists) + { + return QUrl::fromLocalFile(path); } + return QUrl("qrc:/sounds/ping2.wav"); +} + } // namespace +namespace chatterino { + SharedMessageBuilder::SharedMessageBuilder( Channel *_channel, const Communi::IrcPrivateMessage *_ircMessage, const MessageParseArgs &_args) @@ -198,23 +198,8 @@ void SharedMessageBuilder::appendChannelName() ->setLink(link); } -inline QMediaPlayer *getPlayer() -{ - if (isGuiThread()) - { - static auto player = new QMediaPlayer; - return player; - } - else - { - return nullptr; - } -} - void SharedMessageBuilder::triggerHighlights() { - static QUrl currentPlayerUrl; - if (isInStreamerMode() && getSettings()->streamerModeMuteMentions) { // We are in streamer mode with muting mention sounds enabled. Do nothing. @@ -232,19 +217,7 @@ void SharedMessageBuilder::triggerHighlights() if (this->highlightSound_ && resolveFocus) { - if (auto player = getPlayer()) - { - // Set media if the highlight sound url has changed, or if media is buffered - if (currentPlayerUrl != this->highlightSoundUrl_ || - player->mediaStatus() == QMediaPlayer::BufferedMedia) - { - player->setMedia(this->highlightSoundUrl_); - - currentPlayerUrl = this->highlightSoundUrl_; - } - - player->play(); - } + getApp()->sound->play(this->highlightSoundUrl_); } if (this->highlightAlert_) diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index f76948797..7987f34bd 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -35,7 +35,6 @@ #include #include #include -#include #include namespace { diff --git a/src/widgets/settingspages/AboutPage.cpp b/src/widgets/settingspages/AboutPage.cpp index 8f9bc78ae..da9b4a1c7 100644 --- a/src/widgets/settingspages/AboutPage.cpp +++ b/src/widgets/settingspages/AboutPage.cpp @@ -110,6 +110,9 @@ AboutPage::AboutPage() addLicense(form.getElement(), "semver", "https://github.com/Neargye/semver", ":/licenses/semver.txt"); + addLicense(form.getElement(), "miniaudio", + "https://github.com/mackron/miniaudio", + ":/licenses/miniaudio.txt"); } // Attributions