From b33704b0b6f884e5f1be1b663f08a97e1770f9fc Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Fri, 8 Mar 2024 16:03:16 +0300 Subject: [PATCH 01/39] Refactor voice audio handling AudioManager: * Audio devices get started/stopped on voice connect/disconnect * Reduce code duplication with Open/TryOpen functions * Don't store device configs and ids AudioDevices: * Rename [Get/Set]ActivePlaybackDevice to [Get/Set]ActivePlaybackDeviceIter * Declare getters as const and [[nodiscard]] * Reduce code duplication by using Get[Playback/Capture]DeviceIDFromModel * Add GetActive[Playback/Capture] --- src/abaddon.cpp | 2 + src/audio/devices.cpp | 34 ++--- src/audio/devices.hpp | 17 ++- src/audio/manager.cpp | 249 +++++++++++++++++++++--------------- src/audio/manager.hpp | 18 ++- src/windows/voicewindow.cpp | 4 +- 6 files changed, 190 insertions(+), 134 deletions(-) diff --git a/src/abaddon.cpp b/src/abaddon.cpp index 045b8a72..fdb35e64 100644 --- a/src/abaddon.cpp +++ b/src/abaddon.cpp @@ -487,11 +487,13 @@ void Abaddon::DiscordOnThreadUpdate(const ThreadUpdateData &data) { #ifdef WITH_VOICE void Abaddon::OnVoiceConnected() { + m_audio.StartPlaybackDevice(); m_audio.StartCaptureDevice(); ShowVoiceWindow(); } void Abaddon::OnVoiceDisconnected() { + m_audio.StopPlaybackDevice(); m_audio.StopCaptureDevice(); m_audio.RemoveAllSSRCs(); if (m_voice_window != nullptr) { diff --git a/src/audio/devices.cpp b/src/audio/devices.cpp index dfb71640..ad34030d 100644 --- a/src/audio/devices.cpp +++ b/src/audio/devices.cpp @@ -13,11 +13,11 @@ AudioDevices::AudioDevices() , m_capture(Gtk::ListStore::create(m_capture_columns)) { } -Glib::RefPtr AudioDevices::GetPlaybackDeviceModel() { +Glib::RefPtr AudioDevices::GetPlaybackDeviceModel() const { return m_playback; } -Glib::RefPtr AudioDevices::GetCaptureDeviceModel() { +Glib::RefPtr AudioDevices::GetCaptureDeviceModel() const { return m_capture; } @@ -33,7 +33,7 @@ void AudioDevices::SetDevices(ma_device_info *pPlayback, ma_uint32 playback_coun if (d.isDefault) { m_default_playback_iter = row; - SetActivePlaybackDevice(row); + SetActivePlaybackDeviceIter(row); } } @@ -48,7 +48,7 @@ void AudioDevices::SetDevices(ma_device_info *pPlayback, ma_uint32 playback_coun if (d.isDefault) { m_default_capture_iter = row; - SetActiveCaptureDevice(row); + SetActiveCaptureDeviceIter(row); } } @@ -78,34 +78,34 @@ std::optional AudioDevices::GetCaptureDeviceIDFromModel(const Gtk: } std::optional AudioDevices::GetDefaultPlayback() const { - if (m_default_playback_iter) { - return static_cast((*m_default_playback_iter)[m_playback_columns.DeviceID]); - } - - return std::nullopt; + return GetPlaybackDeviceIDFromModel(m_default_playback_iter); } std::optional AudioDevices::GetDefaultCapture() const { - if (m_default_capture_iter) { - return static_cast((*m_default_capture_iter)[m_capture_columns.DeviceID]); - } + return GetCaptureDeviceIDFromModel(m_default_capture_iter); +} - return std::nullopt; +std::optional AudioDevices::GetActivePlayback() const { + return GetPlaybackDeviceIDFromModel(m_active_playback_iter); +} + +std::optional AudioDevices::GetActiveCapture() const { + return GetCaptureDeviceIDFromModel(m_active_capture_iter); } -void AudioDevices::SetActivePlaybackDevice(const Gtk::TreeModel::iterator &iter) { +void AudioDevices::SetActivePlaybackDeviceIter(const Gtk::TreeModel::iterator &iter) { m_active_playback_iter = iter; } -void AudioDevices::SetActiveCaptureDevice(const Gtk::TreeModel::iterator &iter) { +void AudioDevices::SetActiveCaptureDeviceIter(const Gtk::TreeModel::iterator &iter) { m_active_capture_iter = iter; } -Gtk::TreeModel::iterator AudioDevices::GetActivePlaybackDevice() { +Gtk::TreeModel::iterator AudioDevices::GetActivePlaybackDeviceIter() const { return m_active_playback_iter; } -Gtk::TreeModel::iterator AudioDevices::GetActiveCaptureDevice() { +Gtk::TreeModel::iterator AudioDevices::GetActiveCaptureDeviceIter() const { return m_active_capture_iter; } diff --git a/src/audio/devices.hpp b/src/audio/devices.hpp index c83bdb41..18ef0768 100644 --- a/src/audio/devices.hpp +++ b/src/audio/devices.hpp @@ -13,9 +13,6 @@ class AudioDevices { public: AudioDevices(); - Glib::RefPtr GetPlaybackDeviceModel(); - Glib::RefPtr GetCaptureDeviceModel(); - void SetDevices(ma_device_info *pPlayback, ma_uint32 playback_count, ma_device_info *pCapture, ma_uint32 capture_count); [[nodiscard]] std::optional GetPlaybackDeviceIDFromModel(const Gtk::TreeModel::iterator &iter) const; @@ -24,11 +21,17 @@ class AudioDevices { [[nodiscard]] std::optional GetDefaultPlayback() const; [[nodiscard]] std::optional GetDefaultCapture() const; - void SetActivePlaybackDevice(const Gtk::TreeModel::iterator &iter); - void SetActiveCaptureDevice(const Gtk::TreeModel::iterator &iter); + [[nodiscard]] std::optional GetActivePlayback() const; + [[nodiscard]] std::optional GetActiveCapture() const; + + void SetActivePlaybackDeviceIter(const Gtk::TreeModel::iterator &iter); + void SetActiveCaptureDeviceIter(const Gtk::TreeModel::iterator &iter); + + [[nodiscard]] Gtk::TreeModel::iterator GetActivePlaybackDeviceIter() const; + [[nodiscard]] Gtk::TreeModel::iterator GetActiveCaptureDeviceIter() const; - Gtk::TreeModel::iterator GetActivePlaybackDevice(); - Gtk::TreeModel::iterator GetActiveCaptureDevice(); + [[nodiscard]] Glib::RefPtr GetPlaybackDeviceModel() const; + [[nodiscard]] Glib::RefPtr GetCaptureDeviceModel() const; private: class PlaybackColumns : public Gtk::TreeModel::ColumnRecord { diff --git a/src/audio/manager.cpp b/src/audio/manager.cpp index eaac3bff..15462b42 100644 --- a/src/audio/manager.cpp +++ b/src/audio/manager.cpp @@ -130,70 +130,125 @@ AudioManager::AudioManager(const Glib::ustring &backends_string) Enumerate(); - m_playback_config = ma_device_config_init(ma_device_type_playback); - m_playback_config.playback.format = ma_format_f32; - m_playback_config.playback.channels = 2; - m_playback_config.sampleRate = 48000; - m_playback_config.dataCallback = data_callback; - m_playback_config.pUserData = this; - - if (const auto playback_id = m_devices.GetDefaultPlayback(); playback_id.has_value()) { - m_playback_id = *playback_id; - m_playback_config.playback.pDeviceID = &m_playback_id; - - if (auto code = ma_device_init(&m_context, &m_playback_config, &m_playback_device); code != MA_SUCCESS) { - spdlog::get("audio")->error("failed to initialize playback device (code: {})", static_cast(code)); - m_ok = false; - return; - } + Glib::signal_timeout().connect(sigc::mem_fun(*this, &AudioManager::DecayVolumeMeters), 40); +} - if (auto code = ma_device_start(&m_playback_device); code != MA_SUCCESS) { - spdlog::get("audio")->error("failed to start playback (code: {})", static_cast(code)); - ma_device_uninit(&m_playback_device); - m_ok = false; - return; - } +AudioManager::~AudioManager() { + if (m_playback_device_ready) { + ClosePlaybackDevice(); + } - char playback_device_name[MA_MAX_DEVICE_NAME_LENGTH + 1]; - ma_device_get_name(&m_playback_device, ma_device_type_playback, playback_device_name, sizeof(playback_device_name), nullptr); - spdlog::get("audio")->info("using {} as playback device", playback_device_name); + if (m_capture_device_ready) { + CloseCaptureDevice(); } - m_capture_config = ma_device_config_init(ma_device_type_capture); - m_capture_config.capture.format = ma_format_s16; - m_capture_config.capture.channels = 2; - m_capture_config.sampleRate = 48000; - m_capture_config.periodSizeInFrames = 480; - m_capture_config.dataCallback = capture_data_callback; - m_capture_config.pUserData = this; + ma_context_uninit(&m_context); + RemoveAllSSRCs(); - if (const auto capture_id = m_devices.GetDefaultCapture(); capture_id.has_value()) { - m_capture_id = *capture_id; - m_capture_config.capture.pDeviceID = &m_capture_id; +#ifdef WITH_RNNOISE + RNNoiseUninitialize(); +#endif +} - if (auto code = ma_device_init(&m_context, &m_capture_config, &m_capture_device); code != MA_SUCCESS) { - spdlog::get("audio")->error("failed to initialize capture device (code: {})", static_cast(code)); - m_ok = false; - return; - } +void AudioManager::OpenPlaybackDevice(const ma_device_id device_id) { + assert(!m_playback_device_ready && "Tried to open new playback device without closing the current one"); + + auto config = ma_device_config_init(ma_device_type_playback); + config.playback.format = ma_format_f32; + config.playback.channels = 2; + config.playback.pDeviceID = &device_id; + config.sampleRate = 48000; + config.dataCallback = data_callback; + config.pUserData = this; - char capture_device_name[MA_MAX_DEVICE_NAME_LENGTH + 1]; - ma_device_get_name(&m_capture_device, ma_device_type_capture, capture_device_name, sizeof(capture_device_name), nullptr); - spdlog::get("audio")->info("using {} as capture device", capture_device_name); + auto result = ma_device_init(&m_context, &config, &m_playback_device); + if (result != MA_SUCCESS) { + spdlog::get("audio")->error("Failed to initialize playback device (code: {})", static_cast(result)); + return; } - Glib::signal_timeout().connect(sigc::mem_fun(*this, &AudioManager::DecayVolumeMeters), 40); + result = ma_device_start(&m_playback_device); + if (result != MA_SUCCESS) { + spdlog::get("audio")->error("Failed to start playback device (code: {})", static_cast(result)); + + ma_device_uninit(&m_playback_device); + return; + } + + m_playback_device_ready = true; } -AudioManager::~AudioManager() { +void AudioManager::OpenCaptureDevice(const ma_device_id device_id) { + assert(!m_capture_device_ready && "Tried to open new capture device without closing the current one"); + + auto config = ma_device_config_init(ma_device_type_capture); + config.capture.format = ma_format_s16; + config.capture.channels = 2; + config.capture.pDeviceID = &device_id; + config.sampleRate = 48000; + config.periodSizeInFrames = 480; + config.dataCallback = capture_data_callback; + config.pUserData = this; + + auto result = ma_device_init(&m_context, &config, &m_capture_device); + if (result != MA_SUCCESS) { + spdlog::get("audio")->error("Failed to initialize capture device (code: {})", static_cast(result)); + return; + } + + result = ma_device_start(&m_capture_device); + if (result != MA_SUCCESS) { + spdlog::get("audio")->error("Failed to start capture device (code: {})", static_cast(result)); + + ma_device_uninit(&m_capture_device); + return; + } + + m_capture_device_ready = true; +} + +void AudioManager::TryOpenPlaybackDevice(const ma_device_id device_id) { + OpenPlaybackDevice(std::move(device_id)); + + if (m_playback_device_ready) { + char name[MA_MAX_DEVICE_NAME_LENGTH + 1]; + const auto result = ma_device_get_name(&m_playback_device, ma_device_type_playback, name, sizeof(name), nullptr); + + if (result == MA_SUCCESS) { + spdlog::get("audio")->info("Started playback device: {}", name); + } else { + spdlog::get("audio")->info("Started playback device: "); + } + } +} + +void AudioManager::TryOpenCaptureDevice(const ma_device_id device_id) { + OpenCaptureDevice(std::move(device_id)); + + if (m_capture_device_ready) { + char name[MA_MAX_DEVICE_NAME_LENGTH + 1]; + const auto result = ma_device_get_name(&m_capture_device, ma_device_type_capture, name, sizeof(name), nullptr); + + if (result == MA_SUCCESS) { + spdlog::get("audio")->info("Started capture device: {}", name); + } else { + spdlog::get("audio")->info("Started capture device: "); + } + } +} + +void AudioManager::ClosePlaybackDevice() { + assert(m_playback_device_ready && "Tried to close uninitialized playback device"); + ma_device_uninit(&m_playback_device); - ma_device_uninit(&m_capture_device); - ma_context_uninit(&m_context); - RemoveAllSSRCs(); + m_playback_device_ready = false; +} -#ifdef WITH_RNNOISE - RNNoiseUninitialize(); -#endif +void AudioManager::CloseCaptureDevice() { + assert(m_capture_device_ready && "Tried to close uninitialized capture device"); + + ma_device_uninit(&m_capture_device); + m_capture_device_ready = false; } void AudioManager::AddSSRC(uint32_t ssrc) { @@ -227,7 +282,7 @@ void AudioManager::SetOpusBuffer(uint8_t *ptr) { } void AudioManager::FeedMeOpus(uint32_t ssrc, const std::vector &data) { - if (!m_should_playback || ma_device_get_state(&m_playback_device) != ma_device_state_started) return; + if (!m_should_playback || !m_playback_device_ready) return; std::lock_guard _(m_mutex); if (m_muted_ssrcs.find(ssrc) != m_muted_ssrcs.end()) return; @@ -246,86 +301,72 @@ void AudioManager::FeedMeOpus(uint32_t ssrc, const std::vector &data) { } } +void AudioManager::StartPlaybackDevice() { + const auto playback_device_id = m_devices.GetActivePlayback(); + if (!playback_device_id) { + spdlog::get("audio")->warn("No active playback device!"); + return; + } + + TryOpenPlaybackDevice(std::move(*playback_device_id)); +} + +void AudioManager::StopPlaybackDevice() { + if (m_playback_device_ready) { + ClosePlaybackDevice(); + spdlog::get("audio")->info("Closed playback device"); + } +} + void AudioManager::StartCaptureDevice() { - if (ma_device_start(&m_capture_device) != MA_SUCCESS) { - spdlog::get("audio")->error("Failed to start capture device"); + const auto capture_device_id = m_devices.GetActiveCapture(); + if (!capture_device_id) { + spdlog::get("audio")->warn("No active capture device!"); + return; } + + TryOpenCaptureDevice(std::move(*capture_device_id)); } void AudioManager::StopCaptureDevice() { - if (ma_device_stop(&m_capture_device) != MA_SUCCESS) { - spdlog::get("audio")->error("Failed to stop capture device"); + if (m_capture_device_ready) { + CloseCaptureDevice(); + spdlog::get("audio")->info("Closed capture device"); } } void AudioManager::SetPlaybackDevice(const Gtk::TreeModel::iterator &iter) { spdlog::get("audio")->debug("Setting new playback device"); + if (m_playback_device_ready) { + ClosePlaybackDevice(); + } + const auto device_id = m_devices.GetPlaybackDeviceIDFromModel(iter); if (!device_id) { spdlog::get("audio")->error("Requested ID from iterator is invalid"); return; } - m_devices.SetActivePlaybackDevice(iter); - - m_playback_id = *device_id; - - ma_device_uninit(&m_playback_device); - - m_playback_config = ma_device_config_init(ma_device_type_playback); - m_playback_config.playback.format = ma_format_f32; - m_playback_config.playback.channels = 2; - m_playback_config.playback.pDeviceID = &m_playback_id; - m_playback_config.sampleRate = 48000; - m_playback_config.dataCallback = data_callback; - m_playback_config.pUserData = this; - - if (ma_device_init(&m_context, &m_playback_config, &m_playback_device) != MA_SUCCESS) { - spdlog::get("audio")->error("Failed to initialize new device"); - return; - } - - if (ma_device_start(&m_playback_device) != MA_SUCCESS) { - spdlog::get("audio")->error("Failed to start new device"); - return; - } + m_devices.SetActivePlaybackDeviceIter(iter); + TryOpenPlaybackDevice(std::move(*device_id)); } void AudioManager::SetCaptureDevice(const Gtk::TreeModel::iterator &iter) { spdlog::get("audio")->debug("Setting new capture device"); + if (m_capture_device_ready) { + CloseCaptureDevice(); + } + const auto device_id = m_devices.GetCaptureDeviceIDFromModel(iter); if (!device_id) { spdlog::get("audio")->error("Requested ID from iterator is invalid"); return; } - m_devices.SetActiveCaptureDevice(iter); - - m_capture_id = *device_id; - - ma_device_uninit(&m_capture_device); - - m_capture_config = ma_device_config_init(ma_device_type_capture); - m_capture_config.capture.format = ma_format_s16; - m_capture_config.capture.channels = 2; - m_capture_config.capture.pDeviceID = &m_capture_id; - m_capture_config.sampleRate = 48000; - m_capture_config.periodSizeInFrames = 480; - m_capture_config.dataCallback = capture_data_callback; - m_capture_config.pUserData = this; - - if (ma_device_init(&m_context, &m_capture_config, &m_capture_device) != MA_SUCCESS) { - spdlog::get("audio")->error("Failed to initialize new device"); - return; - } - - // technically this should probably try and check old state but if you are in the window to change it then you are connected - if (ma_device_start(&m_capture_device) != MA_SUCCESS) { - spdlog::get("audio")->error("Failed to start new device"); - return; - } + m_devices.SetActiveCaptureDeviceIter(iter); + TryOpenCaptureDevice(std::move(*device_id)); } void AudioManager::SetCapture(bool capture) { diff --git a/src/audio/manager.hpp b/src/audio/manager.hpp index 5716fc55..1e10815b 100644 --- a/src/audio/manager.hpp +++ b/src/audio/manager.hpp @@ -35,7 +35,19 @@ class AudioManager { void SetOpusBuffer(uint8_t *ptr); void FeedMeOpus(uint32_t ssrc, const std::vector &data); + void OpenPlaybackDevice(const ma_device_id device_id); + void OpenCaptureDevice(const ma_device_id device_id); + + void TryOpenPlaybackDevice(const ma_device_id device_id); + void TryOpenCaptureDevice(const ma_device_id device_id); + + void ClosePlaybackDevice(); + void CloseCaptureDevice(); + + void StartPlaybackDevice(); void StartCaptureDevice(); + + void StopPlaybackDevice(); void StopCaptureDevice(); void SetPlaybackDevice(const Gtk::TreeModel::iterator &iter); @@ -120,12 +132,10 @@ class AudioManager { // playback ma_device m_playback_device; - ma_device_config m_playback_config; - ma_device_id m_playback_id; + bool m_playback_device_ready = false; // capture ma_device m_capture_device; - ma_device_config m_capture_config; - ma_device_id m_capture_id; + bool m_capture_device_ready = false; ma_context m_context; diff --git a/src/windows/voicewindow.cpp b/src/windows/voicewindow.cpp index 94ea700a..c7ad2f75 100644 --- a/src/windows/voicewindow.cpp +++ b/src/windows/voicewindow.cpp @@ -190,7 +190,7 @@ VoiceWindow::VoiceWindow(Snowflake channel_id) m_playback_combo.set_hexpand(true); m_playback_combo.set_halign(Gtk::ALIGN_FILL); m_playback_combo.set_model(audio.GetDevices().GetPlaybackDeviceModel()); - if (const auto iter = audio.GetDevices().GetActivePlaybackDevice()) { + if (const auto iter = audio.GetDevices().GetActivePlaybackDeviceIter()) { m_playback_combo.set_active(iter); } m_playback_combo.pack_start(*playback_renderer); @@ -204,7 +204,7 @@ VoiceWindow::VoiceWindow(Snowflake channel_id) m_capture_combo.set_hexpand(true); m_capture_combo.set_halign(Gtk::ALIGN_FILL); m_capture_combo.set_model(Abaddon::Get().GetAudio().GetDevices().GetCaptureDeviceModel()); - if (const auto iter = Abaddon::Get().GetAudio().GetDevices().GetActiveCaptureDevice()) { + if (const auto iter = Abaddon::Get().GetAudio().GetDevices().GetActiveCaptureDeviceIter()) { m_capture_combo.set_active(iter); } m_capture_combo.pack_start(*capture_renderer); From cf6464611dc5d8e0c9fc8b096a924bc1aaab3019 Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Mon, 11 Mar 2024 17:27:45 +0300 Subject: [PATCH 02/39] Apply suggestions - Replace std::move with reference - Log warning instead of assert on opening/closing devices - Remove branching in logging + extract into a function --- src/audio/manager.cpp | 69 ++++++++++++++++++++++++------------------- src/audio/manager.hpp | 9 +++--- 2 files changed, 44 insertions(+), 34 deletions(-) diff --git a/src/audio/manager.cpp b/src/audio/manager.cpp index 15462b42..6d126b65 100644 --- a/src/audio/manager.cpp +++ b/src/audio/manager.cpp @@ -150,8 +150,11 @@ AudioManager::~AudioManager() { #endif } -void AudioManager::OpenPlaybackDevice(const ma_device_id device_id) { - assert(!m_playback_device_ready && "Tried to open new playback device without closing the current one"); +void AudioManager::OpenPlaybackDevice(const ma_device_id &device_id) { + if (m_playback_device_ready) { + spdlog::get("audio")->warn("Tried to open new playback device without closing the current one"); + return; + } auto config = ma_device_config_init(ma_device_type_playback); config.playback.format = ma_format_f32; @@ -178,8 +181,11 @@ void AudioManager::OpenPlaybackDevice(const ma_device_id device_id) { m_playback_device_ready = true; } -void AudioManager::OpenCaptureDevice(const ma_device_id device_id) { - assert(!m_capture_device_ready && "Tried to open new capture device without closing the current one"); +void AudioManager::OpenCaptureDevice(const ma_device_id &device_id) { + if (m_capture_device_ready) { + spdlog::get("audio")->warn("Tried to open new capture device without closing the current one"); + return; + } auto config = ma_device_config_init(ma_device_type_capture); config.capture.format = ma_format_s16; @@ -207,50 +213,53 @@ void AudioManager::OpenCaptureDevice(const ma_device_id device_id) { m_capture_device_ready = true; } -void AudioManager::TryOpenPlaybackDevice(const ma_device_id device_id) { - OpenPlaybackDevice(std::move(device_id)); +void AudioManager::TryOpenPlaybackDevice(const ma_device_id &device_id) { + OpenPlaybackDevice(device_id); if (m_playback_device_ready) { - char name[MA_MAX_DEVICE_NAME_LENGTH + 1]; - const auto result = ma_device_get_name(&m_playback_device, ma_device_type_playback, name, sizeof(name), nullptr); - - if (result == MA_SUCCESS) { - spdlog::get("audio")->info("Started playback device: {}", name); - } else { - spdlog::get("audio")->info("Started playback device: "); - } + LogOpenedDevice(&m_playback_device, ma_device_type_playback); } } -void AudioManager::TryOpenCaptureDevice(const ma_device_id device_id) { - OpenCaptureDevice(std::move(device_id)); +void AudioManager::TryOpenCaptureDevice(const ma_device_id &device_id) { + OpenCaptureDevice(device_id); if (m_capture_device_ready) { - char name[MA_MAX_DEVICE_NAME_LENGTH + 1]; - const auto result = ma_device_get_name(&m_capture_device, ma_device_type_capture, name, sizeof(name), nullptr); - - if (result == MA_SUCCESS) { - spdlog::get("audio")->info("Started capture device: {}", name); - } else { - spdlog::get("audio")->info("Started capture device: "); - } + LogOpenedDevice(&m_capture_device, ma_device_type_capture); } } void AudioManager::ClosePlaybackDevice() { - assert(m_playback_device_ready && "Tried to close uninitialized playback device"); + if(!m_playback_device_ready) { + spdlog::get("audio")->warn("Tried to close uninitialized playback device"); + return; + } ma_device_uninit(&m_playback_device); m_playback_device_ready = false; } void AudioManager::CloseCaptureDevice() { - assert(m_capture_device_ready && "Tried to close uninitialized capture device"); + if(!m_capture_device_ready) { + spdlog::get("audio")->warn("Tried to close uninitialized capture device"); + return; + } ma_device_uninit(&m_capture_device); m_capture_device_ready = false; } +void AudioManager::LogOpenedDevice(ma_device *device, const ma_device_type device_type) { + char name[MA_MAX_DEVICE_NAME_LENGTH + 1] = ""; + const auto result = ma_device_get_name(device, device_type, name, sizeof(name), nullptr); + + if (device_type == ma_device_type_playback) { + spdlog::get("audio")->info("Started playback device: {}", name); + } else if (device_type == ma_device_type_capture) { + spdlog::get("audio")->info("Started capture device: {}", name); + } +} + void AudioManager::AddSSRC(uint32_t ssrc) { std::lock_guard _(m_mutex); int error; @@ -308,7 +317,7 @@ void AudioManager::StartPlaybackDevice() { return; } - TryOpenPlaybackDevice(std::move(*playback_device_id)); + TryOpenPlaybackDevice(*playback_device_id); } void AudioManager::StopPlaybackDevice() { @@ -325,7 +334,7 @@ void AudioManager::StartCaptureDevice() { return; } - TryOpenCaptureDevice(std::move(*capture_device_id)); + TryOpenCaptureDevice(*capture_device_id); } void AudioManager::StopCaptureDevice() { @@ -349,7 +358,7 @@ void AudioManager::SetPlaybackDevice(const Gtk::TreeModel::iterator &iter) { } m_devices.SetActivePlaybackDeviceIter(iter); - TryOpenPlaybackDevice(std::move(*device_id)); + TryOpenPlaybackDevice(*device_id); } void AudioManager::SetCaptureDevice(const Gtk::TreeModel::iterator &iter) { @@ -366,7 +375,7 @@ void AudioManager::SetCaptureDevice(const Gtk::TreeModel::iterator &iter) { } m_devices.SetActiveCaptureDeviceIter(iter); - TryOpenCaptureDevice(std::move(*device_id)); + TryOpenCaptureDevice(*device_id); } void AudioManager::SetCapture(bool capture) { diff --git a/src/audio/manager.hpp b/src/audio/manager.hpp index 1e10815b..02c60735 100644 --- a/src/audio/manager.hpp +++ b/src/audio/manager.hpp @@ -35,11 +35,11 @@ class AudioManager { void SetOpusBuffer(uint8_t *ptr); void FeedMeOpus(uint32_t ssrc, const std::vector &data); - void OpenPlaybackDevice(const ma_device_id device_id); - void OpenCaptureDevice(const ma_device_id device_id); + void OpenPlaybackDevice(const ma_device_id &device_id); + void OpenCaptureDevice(const ma_device_id &device_id); - void TryOpenPlaybackDevice(const ma_device_id device_id); - void TryOpenCaptureDevice(const ma_device_id device_id); + void TryOpenPlaybackDevice(const ma_device_id &device_id); + void TryOpenCaptureDevice(const ma_device_id &device_id); void ClosePlaybackDevice(); void CloseCaptureDevice(); @@ -106,6 +106,7 @@ class AudioManager { bool GetMixMono() const; private: + void LogOpenedDevice(ma_device *device, const ma_device_type device_type); void OnCapturedPCM(const int16_t *pcm, ma_uint32 frames); void UpdateReceiveVolume(uint32_t ssrc, const int16_t *pcm, int frames); From eb1451a50cdaabd4ca1fb34894fef837eaa9eecc Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Sun, 12 May 2024 23:43:11 +0300 Subject: [PATCH 03/39] Implement RAII mutex --- src/misc/mutex.hpp | 101 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 src/misc/mutex.hpp diff --git a/src/misc/mutex.hpp b/src/misc/mutex.hpp new file mode 100644 index 00000000..d64e31b0 --- /dev/null +++ b/src/misc/mutex.hpp @@ -0,0 +1,101 @@ +#pragma once + +// RAII style mutex guard +// Unlocks mutex upon destruction +template +class MutexGuard { +public: + MutexGuard(T &object, std::unique_lock &&lock) : + m_object(object), + m_lock(std::move(lock)) {} + + MutexGuard(MutexGuard &&other) noexcept : + m_lock(std::move(other.m_lock)), + m_object(other.m_object) {} + + // Only implement arrow operator + // Dereference operator would expose the inner object + T* operator->() noexcept { + return &m_object; + } + + const T* operator->() const noexcept { + return &m_object; + } + + void cond_wait(std::condition_variable &condition_var) noexcept { + condition_var.wait(m_lock); + } + + // Provide methods for container types + auto& operator[](size_t index) noexcept { + return m_object[index]; + } + + const auto& operator[](size_t index) const noexcept { + return m_object[index]; + } + + auto begin() noexcept { + return m_object.begin(); + } + + auto end() noexcept { + return m_object.end(); + } + + auto begin() const noexcept { + return m_object.begin(); + } + + auto end() const noexcept { + return m_object.end(); + } + +private: + std::unique_lock m_lock; + T &m_object; +}; + +// RAII style mutex +// Owns the provided object +// Use Lock with MutexGuard or LockScope with closure to access protected object +template +class Mutex { + +public: + Mutex() {} + Mutex(T&& object) : m_object(std::forward(object)) {} + + template + Mutex(Args&&... args) : m_object( T(std::forward(args)...) ) {} + + Mutex(const Mutex&) = delete; + + template + auto LockScope(C closure) noexcept { + std::scoped_lock lock(m_mutex); + return closure(m_object); + } + + template + auto LockScope(C closure) const noexcept { + std::scoped_lock lock(m_mutex); + return closure(m_object); + } + + [[nodiscard]] MutexGuard Lock() noexcept { + std::unique_lock lock(m_mutex); + return MutexGuard(m_object, std::move(lock)); + } + + // Make sure to return const MutexGuard here + [[nodiscard]] const MutexGuard Lock() const noexcept { + std::unique_lock lock(m_mutex); + return MutexGuard(m_object, std::move(lock)); + } + +private: + mutable std::mutex m_mutex; + mutable T m_object; // Needs to be mutable for const to work +}; From 8e1d6816119f277e4cfb4880a112ebf9560765cf Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Sun, 12 May 2024 23:48:44 +0300 Subject: [PATCH 04/39] Implement slice --- src/misc/slice.hpp | 103 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 src/misc/slice.hpp diff --git a/src/misc/slice.hpp b/src/misc/slice.hpp new file mode 100644 index 00000000..b9997808 --- /dev/null +++ b/src/misc/slice.hpp @@ -0,0 +1,103 @@ +#pragma once + +// C++20 span-like slice +// T has to be contigious array-like initialized structure with size of "size * sizeof(T)" +template +class Slice { +public: + using iterator = T*; + using const_iterator = const T*; + + Slice(T* ref, size_t size) : m_ref(ref), m_size(size) {} + Slice(T &ref, size_t size) : m_ref(&ref), m_size(size) {} + + template + Slice(std::array &array) : m_ref(array.data()), m_size(SIZE) {} + + Slice(std::vector &vec) : m_ref(vec.data()), m_size(vec.size()) {} + + T& operator[](size_t i) noexcept { + return m_ref[i]; + } + + const T& operator[](size_t i) const noexcept { + return m_ref[i]; + } + + constexpr iterator begin() noexcept { + return iterator(m_ref); + } + + constexpr const_iterator begin() const noexcept { + return const_iterator(m_ref); + } + + constexpr const_iterator cbegin() const noexcept { + return begin(); + } + + constexpr iterator end() noexcept { + return iterator(m_ref + m_size); + } + + constexpr const_iterator end() const noexcept { + return const_iterator(m_ref + m_size); + } + + constexpr const_iterator cend() const noexcept { + return end(); + } + + constexpr T* data() noexcept { + return m_ref; + } + + constexpr const T* data() const noexcept { + return m_ref; + } + + constexpr size_t size() const noexcept { + return m_size; + } +private: + T* m_ref; + const size_t m_size; +}; + +template +class ConstSlice { +public: + using iterator = const T*; + + ConstSlice(const T* ref, size_t size) : m_ref(ref), m_size(size) {} + ConstSlice(const T& ref, size_t size) : m_ref(&ref), m_size(size) {} + ConstSlice(Slice view) : m_ref(view.data()), m_size(view.size()) {} + + template + ConstSlice(const std::array &array) : m_ref(array.data()), m_size(SIZE) {} + + ConstSlice(const std::vector &vec) : m_ref(vec.data()), m_size(vec.size()) {} + + const T& operator[](size_t i) const noexcept { + return m_ref[i]; + } + + constexpr iterator begin() const noexcept { + return iterator(m_ref); + } + + constexpr iterator end() const noexcept { + return iterator(m_ref + m_size); + } + + constexpr const T* data() const noexcept { + return m_ref; + } + + constexpr size_t size() const noexcept { + return m_size; + } +private: + const T* m_ref; + const size_t m_size; +}; From 0992139d8b218ecb3a2e01024cd570b9f7893c53 Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Sun, 12 May 2024 23:50:49 +0300 Subject: [PATCH 05/39] Implement data channel --- src/misc/channel.hpp | 45 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/misc/channel.hpp diff --git a/src/misc/channel.hpp b/src/misc/channel.hpp new file mode 100644 index 00000000..830b1a27 --- /dev/null +++ b/src/misc/channel.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include "mutex.hpp" + +// Channel with bounded queue size +// Discards the data if the queue is full +template +class Channel { +public: + Channel(size_t capacity) noexcept : m_capacity(capacity) {} + + [[nodiscard]] T Recv() noexcept { + auto queue = m_queue.Lock(); + + if (queue->empty()) { + queue.cond_wait(m_condition_var); + } + + auto data = std::move(queue->front()); + queue->pop_front(); + + return data; + }; + + void Send(T &&data) noexcept { + auto queue = m_queue.Lock(); + + if (queue->size() == m_capacity) { + return; + } + + queue->push_back(std::forward(data)); + m_condition_var.notify_one(); + } + + void ClearQueue() { + m_queue.Lock()->clear(); + } + +private: + Mutex> m_queue; + size_t m_capacity; + + std::condition_variable m_condition_var; +}; From 555a3cd6a0a1cfc4f4bebeaf327162d4b65f74f6 Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Sun, 12 May 2024 23:51:07 +0300 Subject: [PATCH 06/39] Implement thread pool --- src/misc/threadpool.hpp | 68 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/misc/threadpool.hpp diff --git a/src/misc/threadpool.hpp b/src/misc/threadpool.hpp new file mode 100644 index 00000000..42bc8bfd --- /dev/null +++ b/src/misc/threadpool.hpp @@ -0,0 +1,68 @@ +#pragma once + +#include "variant" + +#include "channel.hpp" + +using Terminate = std::monostate; + +template +class ThreadPool { +public: + using ThreadMessage = std::variant; + + ThreadPool() noexcept; + + ThreadPool(Callable callable, size_t channel_capacity) noexcept : + m_callable(std::move(callable)), + m_channel(Channel(channel_capacity)) {} + + ~ThreadPool() noexcept { + Clear(); + } + + void AddThread() noexcept { + if (GetThreadCount() == m_max_threads) { + return; + } + + m_threads.emplace(m_threads.end(), m_callable, std::ref(m_channel)); + m_threads.back().detach(); + } + + void RemoveThread() { + if (m_threads.empty()) { + return; + } + + m_channel.Send(Terminate()); + m_threads.pop_back(); + } + + void Clear() { + const auto thread_count = GetThreadCount(); + + // Clear the queue and send termination messages to remaining threads + m_channel.ClearQueue(); + for (int i = 0; i < thread_count; i++) { + m_channel.Send(Terminate()); + } + + m_threads.clear(); + } + + size_t GetThreadCount() const noexcept { + return m_threads.size(); + } + + void SendToPool(ThreadData &&data) { + m_channel.Send(std::move(data)); + } + + size_t m_max_threads; + +private: + std::vector m_threads; + Channel m_channel; + Callable m_callable; +}; From dbef57a5f64bafc74a13407b3382c1f9aa48c8a8 Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Sun, 12 May 2024 23:57:13 +0300 Subject: [PATCH 07/39] Fix RemoveSSRC never getting called --- src/components/channellist/channellisttree.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/channellist/channellisttree.cpp b/src/components/channellist/channellisttree.cpp index 4816b423..68705475 100644 --- a/src/components/channellist/channellisttree.cpp +++ b/src/components/channellist/channellisttree.cpp @@ -636,6 +636,12 @@ void ChannelListTree::OnVoiceUserDisconnect(Snowflake user_id, Snowflake channel if (auto iter = GetIteratorForRowFromIDOfType(user_id, RenderType::VoiceParticipant)) { m_model->erase(iter); } + + auto& abaddon = Abaddon::Get(); + auto ssrc = abaddon.GetDiscordClient().GetSSRCOfUser(user_id); + if (ssrc) { + abaddon.GetAudio().RemoveSSRC(*ssrc); + } } void ChannelListTree::OnVoiceStateSet(Snowflake user_id, Snowflake channel_id, VoiceStateFlags flags) { From 9b550ed4b9ff8862d439f1868272b9bbd255de32 Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Sun, 12 May 2024 23:58:24 +0300 Subject: [PATCH 08/39] Reimplement audio engine --- src/abaddon.cpp | 8 +- src/audio/audio_device.cpp | 89 +++++ src/audio/audio_device.hpp | 30 ++ src/audio/context.cpp | 80 ++++ src/audio/context.hpp | 35 ++ src/audio/manager.cpp | 389 ++++---------------- src/audio/manager.hpp | 32 +- src/audio/miniaudio/ma_context.cpp | 43 +++ src/audio/miniaudio/ma_context.hpp | 31 ++ src/audio/miniaudio/ma_device.cpp | 44 +++ src/audio/miniaudio/ma_device.hpp | 29 ++ src/audio/utils.hpp | 40 ++ src/audio/voice/capture/constants.hpp | 23 ++ src/audio/voice/capture/effects/gate.cpp | 9 + src/audio/voice/capture/effects/gate.hpp | 14 + src/audio/voice/capture/effects/noise.cpp | 64 ++++ src/audio/voice/capture/effects/noise.hpp | 45 +++ src/audio/voice/capture/voice_capture.cpp | 165 +++++++++ src/audio/voice/capture/voice_capture.hpp | 66 ++++ src/audio/voice/capture/voice_effects.cpp | 68 ++++ src/audio/voice/capture/voice_effects.hpp | 46 +++ src/audio/voice/opus/opus_decoder.cpp | 31 ++ src/audio/voice/opus/opus_decoder.hpp | 29 ++ src/audio/voice/opus/opus_encoder.cpp | 98 +++++ src/audio/voice/opus/opus_encoder.hpp | 62 ++++ src/audio/voice/peak_meter/peak_meter.cpp | 29 ++ src/audio/voice/peak_meter/peak_meter.hpp | 18 + src/audio/voice/playback/client.cpp | 60 +++ src/audio/voice/playback/client.hpp | 37 ++ src/audio/voice/playback/client_store.cpp | 149 ++++++++ src/audio/voice/playback/client_store.hpp | 50 +++ src/audio/voice/playback/constants.hpp | 9 + src/audio/voice/playback/decode_pool.cpp | 75 ++++ src/audio/voice/playback/decode_pool.hpp | 41 +++ src/audio/voice/playback/voice_buffer.cpp | 83 +++++ src/audio/voice/playback/voice_buffer.hpp | 29 ++ src/audio/voice/playback/voice_playback.cpp | 83 +++++ src/audio/voice/playback/voice_playback.hpp | 40 ++ src/audio/voice/voice_audio.cpp | 38 ++ src/audio/voice/voice_audio.hpp | 31 ++ src/discord/voiceclient.cpp | 4 +- 41 files changed, 2001 insertions(+), 345 deletions(-) create mode 100644 src/audio/audio_device.cpp create mode 100644 src/audio/audio_device.hpp create mode 100644 src/audio/context.cpp create mode 100644 src/audio/context.hpp create mode 100644 src/audio/miniaudio/ma_context.cpp create mode 100644 src/audio/miniaudio/ma_context.hpp create mode 100644 src/audio/miniaudio/ma_device.cpp create mode 100644 src/audio/miniaudio/ma_device.hpp create mode 100644 src/audio/utils.hpp create mode 100644 src/audio/voice/capture/constants.hpp create mode 100644 src/audio/voice/capture/effects/gate.cpp create mode 100644 src/audio/voice/capture/effects/gate.hpp create mode 100644 src/audio/voice/capture/effects/noise.cpp create mode 100644 src/audio/voice/capture/effects/noise.hpp create mode 100644 src/audio/voice/capture/voice_capture.cpp create mode 100644 src/audio/voice/capture/voice_capture.hpp create mode 100644 src/audio/voice/capture/voice_effects.cpp create mode 100644 src/audio/voice/capture/voice_effects.hpp create mode 100644 src/audio/voice/opus/opus_decoder.cpp create mode 100644 src/audio/voice/opus/opus_decoder.hpp create mode 100644 src/audio/voice/opus/opus_encoder.cpp create mode 100644 src/audio/voice/opus/opus_encoder.hpp create mode 100644 src/audio/voice/peak_meter/peak_meter.cpp create mode 100644 src/audio/voice/peak_meter/peak_meter.hpp create mode 100644 src/audio/voice/playback/client.cpp create mode 100644 src/audio/voice/playback/client.hpp create mode 100644 src/audio/voice/playback/client_store.cpp create mode 100644 src/audio/voice/playback/client_store.hpp create mode 100644 src/audio/voice/playback/constants.hpp create mode 100644 src/audio/voice/playback/decode_pool.cpp create mode 100644 src/audio/voice/playback/decode_pool.hpp create mode 100644 src/audio/voice/playback/voice_buffer.cpp create mode 100644 src/audio/voice/playback/voice_buffer.hpp create mode 100644 src/audio/voice/playback/voice_playback.cpp create mode 100644 src/audio/voice/playback/voice_playback.hpp create mode 100644 src/audio/voice/voice_audio.cpp create mode 100644 src/audio/voice/voice_audio.hpp diff --git a/src/abaddon.cpp b/src/abaddon.cpp index fdb35e64..72cc4913 100644 --- a/src/abaddon.cpp +++ b/src/abaddon.cpp @@ -487,14 +487,12 @@ void Abaddon::DiscordOnThreadUpdate(const ThreadUpdateData &data) { #ifdef WITH_VOICE void Abaddon::OnVoiceConnected() { - m_audio.StartPlaybackDevice(); - m_audio.StartCaptureDevice(); + m_audio.StartVoice(); ShowVoiceWindow(); } void Abaddon::OnVoiceDisconnected() { - m_audio.StopPlaybackDevice(); - m_audio.StopCaptureDevice(); + m_audio.StopVoice(); m_audio.RemoveAllSSRCs(); if (m_voice_window != nullptr) { m_voice_window->close(); @@ -1131,7 +1129,7 @@ EmojiResource &Abaddon::GetEmojis() { return m_emojis; } -#ifdef WITH_VOICE +#ifdef WITH_MINIAUDIO AudioManager &Abaddon::GetAudio() { return m_audio; } diff --git a/src/audio/audio_device.cpp b/src/audio/audio_device.cpp new file mode 100644 index 00000000..30a64528 --- /dev/null +++ b/src/audio/audio_device.cpp @@ -0,0 +1,89 @@ +#include "audio_device.hpp" + +namespace AbaddonClient::Audio { + +AudioDevice::AudioDevice(Context &context, ma_device_config &&config, std::optional &&device_id) noexcept : + m_context(context), + m_config(std::move(config)), + m_device_id(std::move(device_id)) +{ + SyncDeviceID(); +} + +bool AudioDevice::Start() noexcept { + if (m_started) { + return true; + } + + m_device = Miniaudio::MaDevice::Create(m_context.GetRaw(), m_config); + if (!m_device) { + return false; + } + + m_started = m_device->Start(); + if (!m_started) { + m_device.reset(); + } + + return m_started; +} + +bool AudioDevice::Stop() noexcept { + if (!m_started) { + return true; + } + + m_started = !m_device->Stop(); + + // If we're still running something went wrong + if (m_started) { + return false; + } + + m_device.reset(); + return true; +} + +bool AudioDevice::ChangeDevice(ma_device_id &&device_id) noexcept { + m_device_id = std::move(device_id); + + return RefreshDevice(); +} + +void AudioDevice::SyncDeviceID() noexcept { + if (!m_device_id) { + return; + } + + auto& device_id = *m_device_id; + + switch (m_config.deviceType) { + case ma_device_type_playback: { + m_config.playback.pDeviceID = &device_id; + } break; + + case ma_device_type_capture: { + m_config.capture.pDeviceID = &device_id; + } break; + + case ma_device_type_duplex: { + m_config.playback.pDeviceID = &device_id; + m_config.capture.pDeviceID = &device_id; + } + + case ma_device_type_loopback: { + m_config.capture.pDeviceID = &device_id; + } + } +} + +bool AudioDevice::RefreshDevice() noexcept { + m_device.reset(); + if (m_started) { + return Start(); + } + + return true; +} + +} diff --git a/src/audio/audio_device.hpp b/src/audio/audio_device.hpp new file mode 100644 index 00000000..c9e4ac23 --- /dev/null +++ b/src/audio/audio_device.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include "context.hpp" + +#include "miniaudio/ma_device.hpp" + +namespace AbaddonClient::Audio { + +class AudioDevice { +public: + AudioDevice(Context& context, ma_device_config &&config, std::optional &&device_id) noexcept; + + bool Start() noexcept; + bool Stop() noexcept; + + bool ChangeDevice(ma_device_id &&device_id) noexcept; +private: + void SyncDeviceID() noexcept; + bool RefreshDevice() noexcept; + + bool m_started = false; + + Context &m_context; + std::optional m_device; + + ma_device_config m_config; + std::optional m_device_id; +}; + +} diff --git a/src/audio/context.cpp b/src/audio/context.cpp new file mode 100644 index 00000000..5c4be23e --- /dev/null +++ b/src/audio/context.cpp @@ -0,0 +1,80 @@ +#include "context.hpp" + +namespace AbaddonClient::Audio { + +Context::Context(Miniaudio::MaContext &&context) noexcept : + m_context(std::move(context)) +{ + PopulateDevices(); + FindDefaultDevices(); +} + +std::optional Context::Create(ma_context_config &&config, ConstSlice backends) noexcept { + auto context = Miniaudio::MaContext::Create(std::move(config), backends); + if (!context) { + return std::nullopt; + } + + return std::make_optional(std::move(*context)); +} + + +ConstSlice Context::GetPlaybackDevices() noexcept { + return m_playback_devices; +} + +ConstSlice Context::GetCaptureDevices() noexcept { + return m_capture_devices; +} + +std::optional Context::GetActivePlaybackID() noexcept { + return m_active_playback_id; +} + +std::optional Context::GetActiveCaptureID() noexcept { + return m_active_capture_id; +} + +Miniaudio::MaContext& Context::GetRaw() noexcept { + return *m_context; +} + +void Context::PopulateDevices() noexcept { + auto result = m_context->GetDevices(); + if (!result) { + return; + } + + auto& playback_devices = result->first; + auto& capture_devices = result->second; + + m_playback_devices.reserve(playback_devices.size()); + m_capture_devices.reserve(capture_devices.size()); + + m_playback_devices.assign(playback_devices.begin(), playback_devices.end()); + m_capture_devices.assign(capture_devices.begin(), capture_devices.end()); +} + +void Context::FindDefaultDevices() noexcept { + for (auto& playback : m_playback_devices) { + if (playback.isDefault) { + m_active_playback_id = playback.id; + } + } + + for (auto& capture : m_capture_devices) { + if (capture.isDefault) { + m_active_capture_id = capture.id; + } + } + + if (!m_active_playback_id) { + spdlog::get("audio")->warn("No default playback device found"); + } + + if (!m_active_capture_id) { + spdlog::get("audio")->warn("No default capture device found"); + } +} + +} diff --git a/src/audio/context.hpp b/src/audio/context.hpp new file mode 100644 index 00000000..a9c18108 --- /dev/null +++ b/src/audio/context.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "miniaudio/ma_context.hpp" + +namespace AbaddonClient::Audio { + +class Context { +public: + static std::optional Create(ma_context_config &&config, ConstSlice backends) noexcept; + + ConstSlice GetPlaybackDevices() noexcept; + ConstSlice GetCaptureDevices() noexcept; + + std::optional GetActivePlaybackID() noexcept; + std::optional GetActiveCaptureID() noexcept; + + Miniaudio::MaContext& GetRaw() noexcept; + +private: + Context(Miniaudio::MaContext &&context) noexcept; + + void PopulateDevices() noexcept; + void FindDefaultDevices() noexcept; + + std::optional m_active_playback_id; + std::optional m_active_capture_id; + + std::vector m_playback_devices; + std::vector m_capture_devices; + + std::optional m_context; +}; + + +} diff --git a/src/audio/manager.cpp b/src/audio/manager.cpp index 6d126b65..04ffda7f 100644 --- a/src/audio/manager.cpp +++ b/src/audio/manager.cpp @@ -1,4 +1,4 @@ -#ifdef WITH_VOICE +#ifdef WITH_MINIAUDIO // clang-format off #ifdef _WIN32 @@ -119,30 +119,20 @@ AudioManager::AudioManager(const Glib::ustring &backends_string) backendCount = static_cast(backends_vec.size()); } - if (ma_context_init(pBackends, backendCount, &ctx_cfg, &m_context) != MA_SUCCESS) { - spdlog::get("audio")->error("failed to initialize context"); - m_ok = false; - return; - } + m_context = AbaddonClient::Audio::Context::Create(std::move(ctx_cfg), backends_vec); - const auto backend_name = ma_get_backend_name(m_context.backend); - spdlog::get("audio")->info("Audio backend: {}", backend_name); + if (m_context) { + Enumerate(); - Enumerate(); +#if WITH_VOICE + m_voice.emplace(*m_context); +#endif + } Glib::signal_timeout().connect(sigc::mem_fun(*this, &AudioManager::DecayVolumeMeters), 40); } AudioManager::~AudioManager() { - if (m_playback_device_ready) { - ClosePlaybackDevice(); - } - - if (m_capture_device_ready) { - CloseCaptureDevice(); - } - - ma_context_uninit(&m_context); RemoveAllSSRCs(); #ifdef WITH_RNNOISE @@ -150,140 +140,29 @@ AudioManager::~AudioManager() { #endif } -void AudioManager::OpenPlaybackDevice(const ma_device_id &device_id) { - if (m_playback_device_ready) { - spdlog::get("audio")->warn("Tried to open new playback device without closing the current one"); - return; - } - - auto config = ma_device_config_init(ma_device_type_playback); - config.playback.format = ma_format_f32; - config.playback.channels = 2; - config.playback.pDeviceID = &device_id; - config.sampleRate = 48000; - config.dataCallback = data_callback; - config.pUserData = this; - - auto result = ma_device_init(&m_context, &config, &m_playback_device); - if (result != MA_SUCCESS) { - spdlog::get("audio")->error("Failed to initialize playback device (code: {})", static_cast(result)); - return; - } - - result = ma_device_start(&m_playback_device); - if (result != MA_SUCCESS) { - spdlog::get("audio")->error("Failed to start playback device (code: {})", static_cast(result)); - - ma_device_uninit(&m_playback_device); - return; - } - - m_playback_device_ready = true; -} - -void AudioManager::OpenCaptureDevice(const ma_device_id &device_id) { - if (m_capture_device_ready) { - spdlog::get("audio")->warn("Tried to open new capture device without closing the current one"); - return; - } - - auto config = ma_device_config_init(ma_device_type_capture); - config.capture.format = ma_format_s16; - config.capture.channels = 2; - config.capture.pDeviceID = &device_id; - config.sampleRate = 48000; - config.periodSizeInFrames = 480; - config.dataCallback = capture_data_callback; - config.pUserData = this; - - auto result = ma_device_init(&m_context, &config, &m_capture_device); - if (result != MA_SUCCESS) { - spdlog::get("audio")->error("Failed to initialize capture device (code: {})", static_cast(result)); - return; - } - - result = ma_device_start(&m_capture_device); - if (result != MA_SUCCESS) { - spdlog::get("audio")->error("Failed to start capture device (code: {})", static_cast(result)); - - ma_device_uninit(&m_capture_device); - return; - } - - m_capture_device_ready = true; -} - -void AudioManager::TryOpenPlaybackDevice(const ma_device_id &device_id) { - OpenPlaybackDevice(device_id); - - if (m_playback_device_ready) { - LogOpenedDevice(&m_playback_device, ma_device_type_playback); - } -} - -void AudioManager::TryOpenCaptureDevice(const ma_device_id &device_id) { - OpenCaptureDevice(device_id); - - if (m_capture_device_ready) { - LogOpenedDevice(&m_capture_device, ma_device_type_capture); - } -} - -void AudioManager::ClosePlaybackDevice() { - if(!m_playback_device_ready) { - spdlog::get("audio")->warn("Tried to close uninitialized playback device"); - return; - } - - ma_device_uninit(&m_playback_device); - m_playback_device_ready = false; -} - -void AudioManager::CloseCaptureDevice() { - if(!m_capture_device_ready) { - spdlog::get("audio")->warn("Tried to close uninitialized capture device"); - return; - } - - ma_device_uninit(&m_capture_device); - m_capture_device_ready = false; +void AudioManager::StartVoice() { + m_voice->Start(); } -void AudioManager::LogOpenedDevice(ma_device *device, const ma_device_type device_type) { - char name[MA_MAX_DEVICE_NAME_LENGTH + 1] = ""; - const auto result = ma_device_get_name(device, device_type, name, sizeof(name), nullptr); - - if (device_type == ma_device_type_playback) { - spdlog::get("audio")->info("Started playback device: {}", name); - } else if (device_type == ma_device_type_capture) { - spdlog::get("audio")->info("Started capture device: {}", name); - } +void AudioManager::StopVoice() { + m_voice->Stop(); } void AudioManager::AddSSRC(uint32_t ssrc) { std::lock_guard _(m_mutex); - int error; - if (m_sources.find(ssrc) == m_sources.end()) { - auto *decoder = opus_decoder_create(48000, 2, &error); - m_sources.insert(std::make_pair(ssrc, std::make_pair(std::deque {}, decoder))); - } + m_voice->GetPlayback().GetClientStore().AddClient(ssrc); } void AudioManager::RemoveSSRC(uint32_t ssrc) { std::lock_guard _(m_mutex); - if (auto it = m_sources.find(ssrc); it != m_sources.end()) { - opus_decoder_destroy(it->second.second); - m_sources.erase(it); - } + + m_voice->GetPlayback().GetClientStore().RemoveClient(ssrc); } void AudioManager::RemoveAllSSRCs() { spdlog::get("audio")->info("removing all ssrc"); std::lock_guard _(m_mutex); - for (auto &[ssrc, pair] : m_sources) { - opus_decoder_destroy(pair.second); - } - m_sources.clear(); + m_voice->GetPlayback().GetClientStore().Clear(); } void AudioManager::SetOpusBuffer(uint8_t *ptr) { @@ -291,197 +170,92 @@ void AudioManager::SetOpusBuffer(uint8_t *ptr) { } void AudioManager::FeedMeOpus(uint32_t ssrc, const std::vector &data) { - if (!m_should_playback || !m_playback_device_ready) return; - - std::lock_guard _(m_mutex); - if (m_muted_ssrcs.find(ssrc) != m_muted_ssrcs.end()) return; - - size_t payload_size = 0; - const auto *opus_encoded = StripRTPExtensionHeader(data.data(), static_cast(data.size()), payload_size); - static std::array pcm; - if (auto it = m_sources.find(ssrc); it != m_sources.end()) { - int decoded = opus_decode(it->second.second, opus_encoded, static_cast(payload_size), pcm.data(), 120 * 48, 0); - if (decoded <= 0) { - } else { - UpdateReceiveVolume(ssrc, pcm.data(), decoded); - auto &buf = it->second.first; - buf.insert(buf.end(), pcm.begin(), pcm.begin() + decoded * 2); - } - } -} - -void AudioManager::StartPlaybackDevice() { - const auto playback_device_id = m_devices.GetActivePlayback(); - if (!playback_device_id) { - spdlog::get("audio")->warn("No active playback device!"); - return; - } - - TryOpenPlaybackDevice(*playback_device_id); -} - -void AudioManager::StopPlaybackDevice() { - if (m_playback_device_ready) { - ClosePlaybackDevice(); - spdlog::get("audio")->info("Closed playback device"); - } -} - -void AudioManager::StartCaptureDevice() { - const auto capture_device_id = m_devices.GetActiveCapture(); - if (!capture_device_id) { - spdlog::get("audio")->warn("No active capture device!"); - return; - } - - TryOpenCaptureDevice(*capture_device_id); + m_voice->GetPlayback().OnRTPData(ssrc, std::move(data)); } -void AudioManager::StopCaptureDevice() { - if (m_capture_device_ready) { - CloseCaptureDevice(); - spdlog::get("audio")->info("Closed capture device"); - } -} void AudioManager::SetPlaybackDevice(const Gtk::TreeModel::iterator &iter) { - spdlog::get("audio")->debug("Setting new playback device"); - - if (m_playback_device_ready) { - ClosePlaybackDevice(); - } - - const auto device_id = m_devices.GetPlaybackDeviceIDFromModel(iter); + auto device_id = m_devices.GetPlaybackDeviceIDFromModel(iter); if (!device_id) { spdlog::get("audio")->error("Requested ID from iterator is invalid"); return; } - m_devices.SetActivePlaybackDeviceIter(iter); - TryOpenPlaybackDevice(*device_id); + m_voice->GetPlayback().SetPlaybackDevice(std::move(*device_id)); } void AudioManager::SetCaptureDevice(const Gtk::TreeModel::iterator &iter) { - spdlog::get("audio")->debug("Setting new capture device"); - - if (m_capture_device_ready) { - CloseCaptureDevice(); - } - - const auto device_id = m_devices.GetCaptureDeviceIDFromModel(iter); + auto device_id = m_devices.GetCaptureDeviceIDFromModel(iter); if (!device_id) { spdlog::get("audio")->error("Requested ID from iterator is invalid"); return; } - m_devices.SetActiveCaptureDeviceIter(iter); - TryOpenCaptureDevice(*device_id); + m_voice->GetCapture().SetCaptureDevice(std::move(*device_id)); } void AudioManager::SetCapture(bool capture) { - m_should_capture = capture; + m_voice->GetCapture().SetActive(capture); } void AudioManager::SetPlayback(bool playback) { - m_should_playback = playback; + m_voice->GetPlayback().SetActive(playback); } void AudioManager::SetCaptureGate(double gate) { - m_capture_gate = gate; + m_voice->GetCapture().GetEffects().GetGate().m_vad_threshold = gate; } void AudioManager::SetCaptureGain(double gain) { - m_capture_gain = gain; + m_voice->GetCapture().m_gain = gain; } double AudioManager::GetCaptureGate() const noexcept { - return m_capture_gate; + return m_voice->GetCapture().GetEffects().GetGate().m_vad_threshold; } double AudioManager::GetCaptureGain() const noexcept { - return m_capture_gain; + return m_voice->GetCapture().m_gain; } void AudioManager::SetMuteSSRC(uint32_t ssrc, bool mute) { - std::lock_guard _(m_mutex); - if (mute) { - m_muted_ssrcs.insert(ssrc); - } else { - m_muted_ssrcs.erase(ssrc); - } + m_voice->GetPlayback().GetClientStore().SetClientMute(ssrc, mute); } void AudioManager::SetVolumeSSRC(uint32_t ssrc, double volume) { - std::lock_guard _(m_mutex); - m_volume_ssrc[ssrc] = volume; + m_voice->GetPlayback().GetClientStore().SetClientVolume(ssrc, volume); } double AudioManager::GetVolumeSSRC(uint32_t ssrc) const { - std::lock_guard _(m_mutex); - if (const auto iter = m_volume_ssrc.find(ssrc); iter != m_volume_ssrc.end()) { - return iter->second; - } - return 1.0; + return m_voice->GetPlayback().GetClientStore().GetClientVolume(ssrc); } void AudioManager::SetEncodingApplication(int application) { - std::lock_guard _(m_enc_mutex); - int prev_bitrate = 64000; - if (int err = opus_encoder_ctl(m_encoder, OPUS_GET_BITRATE(&prev_bitrate)); err != OPUS_OK) { - spdlog::get("audio")->error("Failed to get old bitrate when reinitializing: {}", err); - } - opus_encoder_destroy(m_encoder); - int err = 0; - m_encoder = opus_encoder_create(48000, 2, application, &err); - if (err != OPUS_OK) { - spdlog::get("audio")->critical("opus_encoder_create failed: {}", err); - return; - } - - if (int err = opus_encoder_ctl(m_encoder, OPUS_SET_BITRATE(prev_bitrate)); err != OPUS_OK) { - spdlog::get("audio")->error("Failed to set bitrate when reinitializing: {}", err); - } + const auto _application = static_cast(application); + m_voice->GetCapture().GetEncoder()->value().SetEncodingApplication(_application); } int AudioManager::GetEncodingApplication() { - std::lock_guard _(m_enc_mutex); - int temp = OPUS_APPLICATION_VOIP; - if (int err = opus_encoder_ctl(m_encoder, OPUS_GET_APPLICATION(&temp)); err != OPUS_OK) { - spdlog::get("audio")->error("opus_encoder_ctl(OPUS_GET_APPLICATION) failed: {}", err); - } - return temp; + const auto application = m_voice->GetCapture().GetEncoder()->value().GetEncodingApplication(); + return static_cast(application); } void AudioManager::SetSignalHint(int signal) { - std::lock_guard _(m_enc_mutex); - if (int err = opus_encoder_ctl(m_encoder, OPUS_SET_SIGNAL(signal)); err != OPUS_OK) { - spdlog::get("audio")->error("opus_encoder_ctl(OPUS_SET_SIGNAL) failed: {}", err); - } + const auto _signal = static_cast(signal); + m_voice->GetCapture().GetEncoder()->value().SetSignalHint(_signal); } int AudioManager::GetSignalHint() { - std::lock_guard _(m_enc_mutex); - int temp = OPUS_AUTO; - if (int err = opus_encoder_ctl(m_encoder, OPUS_GET_SIGNAL(&temp)); err != OPUS_OK) { - spdlog::get("audio")->error("opus_encoder_ctl(OPUS_GET_SIGNAL) failed: {}", err); - } - return temp; + const auto hint = m_voice->GetCapture().GetEncoder()->value().GetSignalHint(); + return static_cast(hint); } void AudioManager::SetBitrate(int bitrate) { - std::lock_guard _(m_enc_mutex); - if (int err = opus_encoder_ctl(m_encoder, OPUS_SET_BITRATE(bitrate)); err != OPUS_OK) { - spdlog::get("audio")->error("opus_encoder_ctl(OPUS_SET_BITRATE) failed: {}", err); - } + m_voice->GetCapture().GetEncoder()->value().SetBitrate(bitrate); } int AudioManager::GetBitrate() { - std::lock_guard _(m_enc_mutex); - int temp = 64000; - if (int err = opus_encoder_ctl(m_encoder, OPUS_GET_BITRATE(&temp)); err != OPUS_OK) { - spdlog::get("audio")->error("opus_encoder_ctl(OPUS_GET_BITRATE) failed: {}", err); - } - return temp; + return m_voice->GetCapture().GetEncoder()->value().GetBitrate(); } void AudioManager::Enumerate() { @@ -492,19 +266,18 @@ void AudioManager::Enumerate() { spdlog::get("audio")->debug("Enumerating devices"); - if (ma_context_get_devices( - &m_context, - &pPlaybackDeviceInfo, - &playbackDeviceCount, - &pCaptureDeviceInfo, - &captureDeviceCount) != MA_SUCCESS) { - spdlog::get("audio")->error("Failed to enumerate devices"); - return; - } + const auto playback_devices = m_context->GetPlaybackDevices(); + const auto capture_devices = m_context->GetCaptureDevices(); - spdlog::get("audio")->debug("Found {} playback devices and {} capture devices", playbackDeviceCount, captureDeviceCount); + spdlog::get("audio")->info("Found {} playback devices and {} capture devices", playback_devices.size(), capture_devices.size()); - m_devices.SetDevices(pPlaybackDeviceInfo, playbackDeviceCount, pCaptureDeviceInfo, captureDeviceCount); + // I don't know why this does not accept const + m_devices.SetDevices( + const_cast(playback_devices.data()), + playback_devices.size(), + const_cast(capture_devices.data()), + capture_devices.size() + ); } void AudioManager::OnCapturedPCM(const int16_t *pcm, ma_uint32 frames) { @@ -593,18 +366,8 @@ void AudioManager::UpdateCaptureVolume(const int16_t *pcm, ma_uint32 frames) { } bool AudioManager::DecayVolumeMeters() { - m_capture_peak_meter -= 600; - if (m_capture_peak_meter < 0) m_capture_peak_meter = 0; - - const auto x = m_vad_prob.load() - 0.05f; - m_vad_prob.store(x < 0.0f ? 0.0f : x); - - std::lock_guard _(m_vol_mtx); - - for (auto &[ssrc, meter] : m_volumes) { - meter -= 0.01; - if (meter < 0.0) meter = 0.0; - } + m_voice->GetCapture().GetPeakMeter().Decay(); + m_voice->GetPlayback().GetClientStore().DecayPeakMeters(); return true; } @@ -663,15 +426,11 @@ bool AudioManager::OK() const { } double AudioManager::GetCaptureVolumeLevel() const noexcept { - return m_capture_peak_meter / 32768.0; + return m_voice->GetCapture().GetPeakMeter().GetPeak(); } double AudioManager::GetSSRCVolumeLevel(uint32_t ssrc) const noexcept { - std::lock_guard _(m_vol_mtx); - if (const auto it = m_volumes.find(ssrc); it != m_volumes.end()) { - return it->second; - } - return 0.0; + return m_voice->GetPlayback().GetClientStore().GetClientPeakVolume(ssrc); } AudioDevices &AudioManager::GetDevices() { @@ -679,34 +438,24 @@ AudioDevices &AudioManager::GetDevices() { } uint32_t AudioManager::GetRTPTimestamp() const noexcept { - return m_rtp_timestamp; + return m_voice->GetCapture().GetRTPTimestamp(); } void AudioManager::SetVADMethod(const std::string &method) { spdlog::get("audio")->debug("Setting VAD method to {}", method); - if (method == "gate") { - SetVADMethod(VADMethod::Gate); - } else if (method == "rnnoise") { -#ifdef WITH_RNNOISE - SetVADMethod(VADMethod::RNNoise); -#else - SetVADMethod(VADMethod::Gate); - spdlog::get("audio")->error("Tried to set RNNoise VAD method with support disabled"); -#endif - } else { - SetVADMethod(VADMethod::Gate); - spdlog::get("audio")->error("Tried to set unknown VAD method {}", method); - } + m_voice->GetCapture().GetEffects().SetVADMethod(method); } void AudioManager::SetVADMethod(VADMethod method) { const auto method_int = static_cast(method); spdlog::get("audio")->debug("Setting VAD method to enum {}", method_int); - m_vad_method = method; + + m_voice->GetCapture().GetEffects().SetVADMethod(method_int); } AudioManager::VADMethod AudioManager::GetVADMethod() const { - return m_vad_method; + const auto method = m_voice->GetCapture().GetEffects().GetVADMethod(); + return static_cast(method); } std::vector AudioManager::ParseBackendsList(const Glib::ustring &list) { @@ -733,36 +482,36 @@ std::vector AudioManager::ParseBackendsList(const Glib::ustring &lis #ifdef WITH_RNNOISE float AudioManager::GetCurrentVADProbability() const { - return m_vad_prob; + return m_voice->GetCapture().GetEffects().GetNoise().GetPeakMeter().GetPeak(); } double AudioManager::GetRNNProbThreshold() const { - return m_prob_threshold; + return m_voice->GetCapture().GetEffects().GetNoise().m_vad_threshold; } void AudioManager::SetRNNProbThreshold(double value) { - m_prob_threshold = value; + m_voice->GetCapture().GetEffects().GetNoise().m_vad_threshold = value; } void AudioManager::SetSuppressNoise(bool value) { - m_enable_noise_suppression = value; + m_voice->GetCapture().m_suppress_noise = value; } bool AudioManager::GetSuppressNoise() const { - return m_enable_noise_suppression; + return m_voice->GetCapture().m_suppress_noise; } #endif void AudioManager::SetMixMono(bool value) { - m_mix_mono = value; + m_voice->GetCapture().m_mix_mono = value; } bool AudioManager::GetMixMono() const { - return m_mix_mono; + return m_voice->GetCapture().m_mix_mono; } -AudioManager::type_signal_opus_packet AudioManager::signal_opus_packet() { - return m_signal_opus_packet; +AbaddonClient::Audio::Voice::VoiceCapture::CaptureSignal AudioManager::signal_opus_packet() { + return m_voice->GetCapture().GetCaptureSignal(); } #endif diff --git a/src/audio/manager.hpp b/src/audio/manager.hpp index 02c60735..64c56682 100644 --- a/src/audio/manager.hpp +++ b/src/audio/manager.hpp @@ -1,5 +1,5 @@ #pragma once -#ifdef WITH_VOICE +#ifdef WITH_MINIAUDIO // clang-format off #include @@ -21,6 +21,11 @@ #endif #include "devices.hpp" + +#ifdef WITH_VOICE +#include "voice/voice_audio.hpp" +#endif + // clang-format on class AudioManager { @@ -35,20 +40,8 @@ class AudioManager { void SetOpusBuffer(uint8_t *ptr); void FeedMeOpus(uint32_t ssrc, const std::vector &data); - void OpenPlaybackDevice(const ma_device_id &device_id); - void OpenCaptureDevice(const ma_device_id &device_id); - - void TryOpenPlaybackDevice(const ma_device_id &device_id); - void TryOpenCaptureDevice(const ma_device_id &device_id); - - void ClosePlaybackDevice(); - void CloseCaptureDevice(); - - void StartPlaybackDevice(); - void StartCaptureDevice(); - - void StopPlaybackDevice(); - void StopCaptureDevice(); + void StartVoice(); + void StopVoice(); void SetPlaybackDevice(const Gtk::TreeModel::iterator &iter); void SetCaptureDevice(const Gtk::TreeModel::iterator &iter); @@ -106,7 +99,6 @@ class AudioManager { bool GetMixMono() const; private: - void LogOpenedDevice(ma_device *device, const ma_device_type device_type); void OnCapturedPCM(const int16_t *pcm, ma_uint32 frames); void UpdateReceiveVolume(uint32_t ssrc, const int16_t *pcm, int frames); @@ -138,7 +130,7 @@ class AudioManager { ma_device m_capture_device; bool m_capture_device_ready = false; - ma_context m_context; + std::optional m_context; mutable std::mutex m_mutex; mutable std::mutex m_enc_mutex; @@ -171,6 +163,10 @@ class AudioManager { AudioDevices m_devices; +#ifdef WITH_VOICE + std::optional m_voice; +#endif + VADMethod m_vad_method; #ifdef WITH_RNNOISE DenoiseState *m_rnnoise[2]; @@ -182,7 +178,7 @@ class AudioManager { public: using type_signal_opus_packet = sigc::signal; - type_signal_opus_packet signal_opus_packet(); + AbaddonClient::Audio::Voice::VoiceCapture::CaptureSignal signal_opus_packet(); private: type_signal_opus_packet m_signal_opus_packet; diff --git a/src/audio/miniaudio/ma_context.cpp b/src/audio/miniaudio/ma_context.cpp new file mode 100644 index 00000000..a9ca5418 --- /dev/null +++ b/src/audio/miniaudio/ma_context.cpp @@ -0,0 +1,43 @@ +#include "ma_context.hpp" + +namespace AbaddonClient::Audio::Miniaudio { + +MaContext::MaContext(ContextPtr &&context) noexcept : + m_context(std::move(context)) {} + +std::optional MaContext::Create(ma_context_config &&config, ConstSlice backends) noexcept { + ContextPtr context = ContextPtr(new ma_context, &ma_context_uninit); + + const auto result = ma_context_init(backends.data(), backends.size(), &config, context.get()); + if (result != MA_SUCCESS) { + spdlog::get("audio")->error("Failed to create context: {}", ma_result_description(result)); + return std::nullopt; + } + + return std::make_optional(std::move(context)); +} + +std::optional MaContext::GetDevices() noexcept { + ma_device_info* playback_device_infos; + ma_uint32 playback_device_count; + + ma_device_info* capture_device_infos; + ma_uint32 capture_device_count; + + const auto result = ma_context_get_devices(m_context.get(), &playback_device_infos, &playback_device_count, &capture_device_infos, &capture_device_count); + if (result != MA_SUCCESS) { + spdlog::get("audio")->error("Failed to get devices information: {}", ma_result_description(result)); + return std::nullopt; + } + + const auto playback_info = PlaybackDeviceInfo(playback_device_infos, playback_device_count); + const auto capture_info = CaptureDeviceInfo(capture_device_infos, capture_device_count); + + return std::make_optional(std::move(playback_info), std::move(capture_info)); +} + +ma_context& MaContext::GetInternal() noexcept { + return *m_context; +} + +} diff --git a/src/audio/miniaudio/ma_context.hpp b/src/audio/miniaudio/ma_context.hpp new file mode 100644 index 00000000..bf69b20b --- /dev/null +++ b/src/audio/miniaudio/ma_context.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include "misc/slice.hpp" + +#include + +namespace AbaddonClient::Audio::Miniaudio { + +class MaContext { +public: + using PlaybackDeviceInfo = ConstSlice; + using CaptureDeviceInfo = ConstSlice; + using DeviceInfo = std::pair; + + static std::optional Create(ma_context_config &&config, ConstSlice backends) noexcept; + + std::optional GetDevices() noexcept; + + ma_context& GetInternal() noexcept; + +private: + // Put ma_context behind pointer to allow moving. + // miniaudio expects ma_context reference to be valid at all times + // Moving it to other location would cause memory corruption + using ContextPtr = std::unique_ptr; + MaContext(ContextPtr &&context) noexcept; + + ContextPtr m_context; +}; + +} diff --git a/src/audio/miniaudio/ma_device.cpp b/src/audio/miniaudio/ma_device.cpp new file mode 100644 index 00000000..ea6c9e28 --- /dev/null +++ b/src/audio/miniaudio/ma_device.cpp @@ -0,0 +1,44 @@ +#include "ma_device.hpp" + +namespace AbaddonClient::Audio::Miniaudio { + +MaDevice::MaDevice(DevicePtr &&device) noexcept : + m_device(std::move(device)) {} + +std::optional MaDevice::Create(MaContext &context, ma_device_config &config) noexcept { + DevicePtr device = DevicePtr(new ma_device, &ma_device_uninit); + + const auto result = ma_device_init(&context.GetInternal(), &config, device.get()); + if (result != MA_SUCCESS) { + spdlog::get("audio")->error("Failed to create MaDevice: {}", ma_result_description(result)); + return std::nullopt; + } + + return std::make_optional(std::move(device)); +} + +bool MaDevice::Start() noexcept { + const auto result = ma_device_start(m_device.get()); + if (result != MA_SUCCESS) { + spdlog::get("audio")->error("Failed to start device: {}", ma_result_description(result)); + return false; + } + + return true; +} + +bool MaDevice::Stop() noexcept { + const auto result = ma_device_stop(m_device.get()); + if (result != MA_SUCCESS) { + spdlog::get("audio")->error("Failed to stop device: {}", ma_result_description(result)); + return false; + } + + return true; +} + +ma_device& MaDevice::GetInternal() noexcept { + return *m_device; +} + +} diff --git a/src/audio/miniaudio/ma_device.hpp b/src/audio/miniaudio/ma_device.hpp new file mode 100644 index 00000000..4c891b7c --- /dev/null +++ b/src/audio/miniaudio/ma_device.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include + +#include "ma_context.hpp" + +namespace AbaddonClient::Audio::Miniaudio { + +class MaDevice { +public: + static std::optional Create(MaContext &context, ma_device_config &config) noexcept; + + bool Start() noexcept; + bool Stop() noexcept; + + ma_device& GetInternal() noexcept; + +private: + // Put ma_device behind pointer to allow moving + // miniaudio expects ma_device reference to be valid at all times. + + // Moving it to other location would cause memory corruption + using DevicePtr = std::unique_ptr; + MaDevice(DevicePtr &&device) noexcept; + + DevicePtr m_device; +}; + +} diff --git a/src/audio/utils.hpp b/src/audio/utils.hpp new file mode 100644 index 00000000..bc55bd22 --- /dev/null +++ b/src/audio/utils.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include "misc/slice.hpp" + +using InputBuffer = ConstSlice; +using OutputBuffer = Slice; + +namespace AbaddonClient::Audio { + +class AudioUtils { +public: + AudioUtils() = delete; + ~AudioUtils() = delete; + + static void ApplyGain(OutputBuffer buffer, float gain) noexcept { + for (auto &sample : buffer) { + sample *= gain; + } + } + + static void ClampToFloatRange(OutputBuffer buffer) noexcept { + for (auto& sample : buffer) { + sample = std::clamp(sample, -1.0f, 1.0f); + } + } + + static void MixStereoToMono(OutputBuffer buffer) noexcept { + for (auto iter = buffer.begin(); iter < buffer.end() - 2; iter += 2) { + const auto mixed = std::reduce(iter, iter + 2, 0) / 2.0f; + std::fill(iter, iter + 2, mixed); + } + } + + static void MixBuffers(InputBuffer first, OutputBuffer second) noexcept { + std::transform(first.begin(), first.end(), second.begin(), second.begin(), std::plus()); + } + +}; + +} diff --git a/src/audio/voice/capture/constants.hpp b/src/audio/voice/capture/constants.hpp new file mode 100644 index 00000000..f5a41303 --- /dev/null +++ b/src/audio/voice/capture/constants.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include + +constexpr ma_format CAPTURE_FORMAT = ma_format_f32; +constexpr uint32_t CAPTURE_SAMPLE_RATE = 48000; +constexpr uint32_t CAPTURE_CHANNELS = 2; +constexpr uint32_t CAPTURE_FRAME_SIZE = 480; +constexpr size_t CAPTURE_BUFFER_SIZE = CAPTURE_FRAME_SIZE * CAPTURE_CHANNELS; + +using CaptureBuffer = std::array; + +constexpr size_t OPUS_LATENCY_TENTH_MS = 10000 * CAPTURE_FRAME_SIZE / CAPTURE_SAMPLE_RATE; +static_assert +( + OPUS_LATENCY_TENTH_MS == 25 || + OPUS_LATENCY_TENTH_MS == 50 || + OPUS_LATENCY_TENTH_MS == 100 || + OPUS_LATENCY_TENTH_MS == 200 || + OPUS_LATENCY_TENTH_MS == 400 || + OPUS_LATENCY_TENTH_MS == 600, + "Opus latency should be either 2.5, 5, 10, 20, 40 or 60 ms" +); diff --git a/src/audio/voice/capture/effects/gate.cpp b/src/audio/voice/capture/effects/gate.cpp new file mode 100644 index 00000000..87618b02 --- /dev/null +++ b/src/audio/voice/capture/effects/gate.cpp @@ -0,0 +1,9 @@ +#include "gate.hpp" + +namespace AbaddonClient::Audio::Voice::Capture::Effects { + +bool Gate::PassesVAD(InputBuffer buffer, float current_peak) const noexcept { + return current_peak > m_vad_threshold; +} + +} diff --git a/src/audio/voice/capture/effects/gate.hpp b/src/audio/voice/capture/effects/gate.hpp new file mode 100644 index 00000000..510d7945 --- /dev/null +++ b/src/audio/voice/capture/effects/gate.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include "audio/utils.hpp" + +namespace AbaddonClient::Audio::Voice::Capture::Effects { + +class Gate { +public: + bool PassesVAD(InputBuffer buffer, float current_peak) const noexcept; + + std::atomic m_vad_threshold; +}; + +} diff --git a/src/audio/voice/capture/effects/noise.cpp b/src/audio/voice/capture/effects/noise.cpp new file mode 100644 index 00000000..4d680f48 --- /dev/null +++ b/src/audio/voice/capture/effects/noise.cpp @@ -0,0 +1,64 @@ +#include "noise.hpp" + +namespace AbaddonClient::Audio::Voice::Capture::Effects { + +bool Noise::PassesVAD(InputBuffer buffer) noexcept { + // Use first channel for VAD, only denoise the rest if noise suppression is enabled + const auto prob = m_channels.Lock()[0].DenoiseChannel(buffer, 0); + + m_peak_meter.SetPeak(prob); + denoised_first_channel = true; + + return prob > m_vad_threshold; +} + +void Noise::Denoise(OutputBuffer buffer) noexcept { + auto start = 0; + if (denoised_first_channel) { + denoised_first_channel = false; + start = 1; + } + + auto channels = m_channels.Lock(); + for (size_t channel = start; channel < channels->size(); channel++) { + auto& channel_buffer = channels[channel]; + + channel_buffer.DenoiseChannel(buffer, channel); + channel_buffer.WriteChannel(buffer, channel); + } +} + +PeakMeter& Noise::GetPeakMeter() noexcept { + return m_peak_meter; +} + +const PeakMeter& Noise::GetPeakMeter() const noexcept { + return m_peak_meter; +} + +NoiseBuffer::NoiseBuffer() noexcept : + m_state(RNNoisePtr(rnnoise_create(NULL), rnnoise_destroy)) {} + +float NoiseBuffer::DenoiseChannel(InputBuffer buffer, size_t channel) noexcept { + ChannelBuffer input_channel; + + // Copy interleaved samples from a specific channel + for (size_t i = 0; i < m_channel.size(); i++) { + const auto offset = channel + (i * CAPTURE_CHANNELS); + + // RNNoise expects samples to fall in range of 16-bit PCM, but as float. Weird + input_channel[i] = buffer[offset] * INT16_MAX; + } + + const auto prob = rnnoise_process_frame(m_state.get(), m_channel.data(), input_channel.data()); + return prob; +} + +void NoiseBuffer::WriteChannel(OutputBuffer buffer, size_t channel) noexcept { + for (size_t i = 0; i < m_channel.size(); i++) { + const auto offset = channel + (i * CAPTURE_CHANNELS); + buffer[offset] = m_channel[i] / INT16_MAX; // Convert to f32 sample range + } +} + +} diff --git a/src/audio/voice/capture/effects/noise.hpp b/src/audio/voice/capture/effects/noise.hpp new file mode 100644 index 00000000..d67ce595 --- /dev/null +++ b/src/audio/voice/capture/effects/noise.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include "misc/mutex.hpp" + +#include "audio/voice/capture/constants.hpp" +#include "audio/voice/peak_meter/peak_meter.hpp" + +#include "rnnoise.h" + +namespace AbaddonClient::Audio::Voice::Capture::Effects { + +class NoiseBuffer { +public: + NoiseBuffer() noexcept; + + float DenoiseChannel(InputBuffer buffer, size_t channel) noexcept; + void WriteChannel(OutputBuffer buffer, size_t channel) noexcept; + +private: + using RNNoisePtr = std::unique_ptr; + using ChannelBuffer = std::array; + + RNNoisePtr m_state; + ChannelBuffer m_channel; +}; + +class Noise { +public: + bool PassesVAD(InputBuffer buffer) noexcept; + void Denoise(OutputBuffer buffer) noexcept; + + PeakMeter& GetPeakMeter() noexcept; + const PeakMeter& GetPeakMeter() const noexcept; + + std::atomic m_vad_threshold = 0.5; +private: + using NoiseArray = std::array; + + Mutex m_channels; + PeakMeter m_peak_meter; + + bool denoised_first_channel; +}; + +}; diff --git a/src/audio/voice/capture/voice_capture.cpp b/src/audio/voice/capture/voice_capture.cpp new file mode 100644 index 00000000..ea73ade3 --- /dev/null +++ b/src/audio/voice/capture/voice_capture.cpp @@ -0,0 +1,165 @@ +#include "voice_capture.hpp" +#include "constants.hpp" + +namespace AbaddonClient::Audio::Voice { + +using OpusEncoder = Opus::OpusEncoder; +using EncoderSettings = OpusEncoder::EncoderSettings; +using SignalHint = OpusEncoder::SignalHint; +using EncodingApplication = OpusEncoder::EncodingApplication; + +void capture_callback(ma_device* pDevice, void* pOutput, const void* pInput, ma_uint32 frameCount) { + auto capture = reinterpret_cast(pDevice->pUserData); + if (capture == nullptr) { + return; + } + + const auto buffer = InputBuffer(static_cast(pInput), CAPTURE_BUFFER_SIZE); + capture->OnAudioCapture(buffer); +} + +VoiceCapture::VoiceCapture(Context &context) noexcept : + m_device(context, GetDeviceConfig(), context.GetActiveCaptureID()) {} + +void VoiceCapture::Start() noexcept { + StartEncoder(); + if (!m_encoder.Lock()->has_value()) { + return; + } + + m_device.Start(); +} + +void VoiceCapture::Stop() noexcept { + m_device.Stop(); + m_encoder.Lock()->reset(); +} + +void VoiceCapture::SetActive(bool active) noexcept { + m_active = active; +} + +void VoiceCapture::SetCaptureDevice(ma_device_id &&device_id) noexcept { + spdlog::get("voice")->info("Setting capture device"); + + const auto success = m_device.ChangeDevice(std::move(device_id)); + if (!success) { + spdlog::get("voice")->error("Failed to set capture device"); + } +} + +ma_device_config VoiceCapture::GetDeviceConfig() noexcept { + auto config = ma_device_config_init(ma_device_type_capture); + config.capture.format = CAPTURE_FORMAT; + config.sampleRate = CAPTURE_SAMPLE_RATE; + config.capture.channels = CAPTURE_CHANNELS; + config.pulse.pStreamNameCapture = "Abaddon (Capture)"; + + config.periodSizeInFrames = CAPTURE_FRAME_SIZE; + config.performanceProfile = ma_performance_profile_low_latency; + + config.dataCallback = capture_callback; + config.pUserData = this; + + return config; +} + +void VoiceCapture::StartEncoder() noexcept { + auto settings = EncoderSettings {}; + settings.sample_rate = CAPTURE_SAMPLE_RATE; + settings.channels = CAPTURE_CHANNELS; + settings.bitrate = 64000; + settings.signal_hint = SignalHint::Auto; + settings.application = EncodingApplication::Audio; + + auto encoder = OpusEncoder::Create(settings); + if (!encoder) { + return; + } + + m_encoder.Lock()->emplace(std::move(*encoder)); +} + +void VoiceCapture::OnAudioCapture(InputBuffer input) noexcept { + if (!m_active) { + return; + } + + CaptureBuffer buffer; + std::copy(input.begin(), input.end(), buffer.begin()); + + ApplyEffects(buffer); + if (ApplyNoise(buffer)) { + EncodeAndSend(std::move(buffer)); + } +} + +void VoiceCapture::ApplyEffects(CaptureBuffer &buffer) noexcept { + if (m_mix_mono) { + AudioUtils::MixStereoToMono(buffer); + } + + AudioUtils::ApplyGain(buffer, m_gain); + m_peak_meter.UpdatePeak(buffer); +} + +bool VoiceCapture::ApplyNoise(CaptureBuffer &buffer) noexcept { + if(!m_effects.PassesVAD(buffer, m_peak_meter.GetPeak())) { + return false; + } + + if (m_suppress_noise) { + m_effects.Denoise(buffer); + } + + return true; +} + +void VoiceCapture::EncodeAndSend(CaptureBuffer &&buffer) noexcept { + std::vector opus; + opus.resize(1275); + + const auto bytes = m_encoder.Lock()->value().Encode(buffer, opus, CAPTURE_FRAME_SIZE); + opus.resize(bytes); + + if (bytes > 0) { + m_signal.emit(std::move(opus)); + } + + m_rtp_timestamp += CAPTURE_FRAME_SIZE; +} + +Capture::VoiceEffects& VoiceCapture::GetEffects() noexcept { + return m_effects; +} + +const Capture::VoiceEffects& VoiceCapture::GetEffects() const noexcept { + return m_effects; +} + +PeakMeter& VoiceCapture::GetPeakMeter() noexcept { + return m_peak_meter; +} + +const PeakMeter& VoiceCapture::GetPeakMeter() const noexcept { + return m_peak_meter; +} + +MutexGuard> VoiceCapture::GetEncoder() noexcept { + return m_encoder.Lock(); +} + +const MutexGuard> VoiceCapture::GetEncoder() const noexcept { + return m_encoder.Lock(); +} + +VoiceCapture::CaptureSignal VoiceCapture::GetCaptureSignal() const noexcept { + return m_signal; +} + +uint32_t VoiceCapture::GetRTPTimestamp() const noexcept { + return m_rtp_timestamp; +} + + +} diff --git a/src/audio/voice/capture/voice_capture.hpp b/src/audio/voice/capture/voice_capture.hpp new file mode 100644 index 00000000..a8c4c9fe --- /dev/null +++ b/src/audio/voice/capture/voice_capture.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include "audio/audio_device.hpp" +#include "audio/context.hpp" +#include "audio/utils.hpp" + +#include "audio/voice/opus/opus_encoder.hpp" +#include "audio/voice/peak_meter/peak_meter.hpp" + +#include "voice_effects.hpp" + +namespace AbaddonClient::Audio::Voice { + +class VoiceCapture { +public: + using CaptureSignal = sigc::signal)>; + + VoiceCapture(Context &context) noexcept; + + void Start() noexcept; + void Stop() noexcept; + + void SetActive(bool active) noexcept; + void SetCaptureDevice(ma_device_id &&device_id) noexcept; + + Capture::VoiceEffects& GetEffects() noexcept; + const Capture::VoiceEffects& GetEffects() const noexcept; + + MutexGuard> GetEncoder() noexcept; + const MutexGuard> GetEncoder() const noexcept; + + PeakMeter& GetPeakMeter() noexcept; + const PeakMeter& GetPeakMeter() const noexcept; + + CaptureSignal GetCaptureSignal() const noexcept; + uint32_t GetRTPTimestamp() const noexcept; + + std::atomic m_gain = 1.0f; + std::atomic m_mix_mono = false; + std::atomic m_suppress_noise = false; +private: + ma_device_config GetDeviceConfig() noexcept; + void StartEncoder() noexcept; + + void OnAudioCapture(const InputBuffer input_buffer) noexcept; + + void ApplyEffects(CaptureBuffer &buffer) noexcept; + bool ApplyNoise(CaptureBuffer &buffer) noexcept; + void EncodeAndSend(CaptureBuffer &&buffer) noexcept; + + Capture::VoiceEffects m_effects; + PeakMeter m_peak_meter; + + AudioDevice m_device; + Mutex> m_encoder; + CaptureSignal m_signal; + + // TODO: Ideally this should not be here + // RTP should be handled by VoiceClient + std::atomic m_rtp_timestamp = 0; + std::atomic m_active = true; + + friend void capture_callback(ma_device* pDevice, void* pOutput, const void* pInput, ma_uint32 frameCount); +}; + +} diff --git a/src/audio/voice/capture/voice_effects.cpp b/src/audio/voice/capture/voice_effects.cpp new file mode 100644 index 00000000..2f907bba --- /dev/null +++ b/src/audio/voice/capture/voice_effects.cpp @@ -0,0 +1,68 @@ +#include "voice_effects.hpp" + +namespace AbaddonClient::Audio::Voice::Capture { + +bool VoiceEffects::PassesVAD(InputBuffer buffer, float current_peak) noexcept { + switch (m_vad_method) { + case VADMethod::Gate: { + return m_gate.PassesVAD(buffer, current_peak); + } +#ifdef WITH_RNNOISE + case VADMethod::RNNoise: { + return m_noise.PassesVAD(buffer); + } +#endif + } + + return true; +}; + +void VoiceEffects::Denoise(OutputBuffer buffer) noexcept { +#ifdef WITH_RNNOISE + m_noise.Denoise(buffer); +#endif +} + +void VoiceEffects::SetVADMethod(const std::string &method) noexcept { + if (method == "gate") { + m_vad_method = VADMethod::Gate; + } +#ifdef WITH_RNNOISE + else if (method == "rnnoise") { + m_vad_method = VADMethod::RNNoise; + } +#endif + else { + spdlog::get("voice")->error("Tried to set non-existent VAD method: {}", method); + } +} + +void VoiceEffects::SetVADMethod(int method) noexcept { + m_vad_method = static_cast(method); +} + +int VoiceEffects::GetVADMethod() const noexcept { + return static_cast(m_vad_method); +} + +Effects::Gate& VoiceEffects::GetGate() noexcept { + return m_gate; +} + +const Effects::Gate& VoiceEffects::GetGate() const noexcept { + return m_gate; +} + +#ifdef WITH_RNNOISE + +Effects::Noise& VoiceEffects::GetNoise() noexcept { + return m_noise; +} + +const Effects::Noise& VoiceEffects::GetNoise() const noexcept { + return m_noise; +} + +#endif + +} diff --git a/src/audio/voice/capture/voice_effects.hpp b/src/audio/voice/capture/voice_effects.hpp new file mode 100644 index 00000000..1694192b --- /dev/null +++ b/src/audio/voice/capture/voice_effects.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include "audio/utils.hpp" + +#include "effects/gate.hpp" + +#ifdef WITH_RNNOISE +#include "effects/noise.hpp" +#endif + +namespace AbaddonClient::Audio::Voice::Capture { + +enum class VADMethod { + Gate, +#ifdef WITH_RNNOISE + RNNoise +#endif +}; + +class VoiceEffects { +public: + bool PassesVAD(InputBuffer buffer, float current_volume) noexcept; + void Denoise(OutputBuffer buffer) noexcept; + + void SetVADMethod(const std::string &method) noexcept; + void SetVADMethod(int method) noexcept; + int GetVADMethod() const noexcept; + + Effects::Gate& GetGate() noexcept; + const Effects::Gate& GetGate() const noexcept; + +#ifdef WITH_RNNOISE + Effects::Noise& GetNoise() noexcept; + const Effects::Noise& GetNoise() const noexcept; +#endif + + VADMethod m_vad_method; +private: + Effects::Gate m_gate; + +#ifdef WITH_RNNOISE + Effects::Noise m_noise; +#endif +}; + +}; diff --git a/src/audio/voice/opus/opus_decoder.cpp b/src/audio/voice/opus/opus_decoder.cpp new file mode 100644 index 00000000..dae410d7 --- /dev/null +++ b/src/audio/voice/opus/opus_decoder.cpp @@ -0,0 +1,31 @@ +#include "opus_decoder.hpp" + +namespace AbaddonClient::Audio::Voice::Opus { + +OpusDecoder::OpusDecoder(DecoderPtr encoder, DecoderSettings settings) noexcept : + m_encoder(std::move(encoder)) {} + +std::optional +OpusDecoder::Create(const DecoderSettings settings) noexcept { + int error; + const auto decoder = opus_decoder_create(settings.sample_rate, settings.channels, &error); + + if (error != OPUS_OK) { + spdlog::get("voice")->error("Failed to create opus decoder: {}", opus_strerror(error)); + return std::nullopt; + } + + auto decoder_ptr = DecoderPtr(decoder, opus_decoder_destroy); + return std::make_optional(std::move(decoder_ptr), std::move(settings)); +} + +int OpusDecoder::Decode(OpusInput opus, OutputBuffer output, const int frame_size) noexcept { + const auto frames = opus_decode_float(m_encoder.get(), opus.data(), opus.size(), output.data(), frame_size, 0); + if (frames < 0) { + spdlog::get("voice")->error("Opus decoder error: {}", opus_strerror(frames)); + } + + return frames; +} + +} diff --git a/src/audio/voice/opus/opus_decoder.hpp b/src/audio/voice/opus/opus_decoder.hpp new file mode 100644 index 00000000..f3a375c4 --- /dev/null +++ b/src/audio/voice/opus/opus_decoder.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include + +#include "audio/utils.hpp" + +namespace AbaddonClient::Audio::Voice::Opus { + +using OpusInput = ConstSlice; + +class OpusDecoder { +public: + using DecoderPtr = std::unique_ptr<::OpusDecoder, decltype(&opus_decoder_destroy)>; + + struct DecoderSettings { + int32_t sample_rate = 48000; + int channels = 2; + }; + + OpusDecoder(DecoderPtr encoder, DecoderSettings settings) noexcept; + static std::optional Create(DecoderSettings settings) noexcept; + + int Decode(OpusInput opus, OutputBuffer pcm, int frame_size) noexcept; +private: + + DecoderPtr m_encoder; +}; + +} diff --git a/src/audio/voice/opus/opus_encoder.cpp b/src/audio/voice/opus/opus_encoder.cpp new file mode 100644 index 00000000..2b26dd3b --- /dev/null +++ b/src/audio/voice/opus/opus_encoder.cpp @@ -0,0 +1,98 @@ +#include "opus_encoder.hpp" + +namespace AbaddonClient::Audio::Voice::Opus { + +using EncoderSettings = OpusEncoder::EncoderSettings; +using EncodingApplication = OpusEncoder::EncodingApplication; +using SignalHint = OpusEncoder::SignalHint; + +OpusEncoder::OpusEncoder(EncoderPtr encoder, EncoderSettings settings) noexcept : + m_encoder(std::move(encoder)), + m_sample_rate(settings.sample_rate), + m_channels(settings.channels), + m_bitrate(settings.bitrate), + m_signal_hint(settings.signal_hint), + m_application(settings.application) +{ + SetBitrate(m_bitrate); + SetSignalHint(m_signal_hint); +} + +std::optional +OpusEncoder::Create(EncoderSettings settings) noexcept { + int error; + const auto encoder = opus_encoder_create(settings.sample_rate, settings.channels, static_cast(settings.application), &error); + + if (error != OPUS_OK) { + spdlog::get("voice")->error("Cannot create opus encoder: {}", opus_strerror(error)); + return std::nullopt; + } + + auto encoder_ptr = EncoderPtr(encoder, opus_encoder_destroy); + return std::make_optional(std::move(encoder_ptr), std::move(settings)); +} + +int OpusEncoder::Encode(InputBuffer &&input, OpusOutput &&output, int frame_size) noexcept { + const auto bytes = opus_encode_float(m_encoder.get(), input.data(), frame_size, output.data(), output.size()); + if (bytes < 0) { + spdlog::get("voice")->error("Opus encoder error: {}", opus_strerror(bytes)); + } + + return bytes; +} + +void OpusEncoder::ResetState() noexcept { + OpusCTL("OPUS_RESET_STATE", OPUS_RESET_STATE); +} + +void OpusEncoder::SetBitrate(int32_t bitrate) noexcept { + const auto success = OpusCTL("OPUS_SET_BITRATE", OPUS_SET_BITRATE(bitrate)); + + if (success) { + m_bitrate = bitrate; + } +} + +void OpusEncoder::SetSignalHint(SignalHint hint) noexcept { + const auto hint_int = static_cast(hint); + const auto success = OpusCTL("OPUS_SET_SIGNAL", OPUS_SET_SIGNAL(hint_int)); + + if (success) { + m_signal_hint = hint; + } +} + +void OpusEncoder::SetEncodingApplication(EncodingApplication application) noexcept { + // TODO: It should be fine to omit the error check here since we're only changing the application? + int error; + auto ptr = opus_encoder_create(m_sample_rate, m_channels, static_cast(application), &error); + m_encoder.reset(ptr); + + SetBitrate(m_bitrate); + SetSignalHint(m_signal_hint); +} + +int32_t OpusEncoder::GetBitrate() const noexcept { + return m_bitrate; +} + +EncodingApplication OpusEncoder::GetEncodingApplication() const noexcept { + return m_application; +} + +SignalHint OpusEncoder::GetSignalHint() const noexcept { + return m_signal_hint; +} + +template +bool OpusEncoder::OpusCTL(std::string_view request, Args&& ...args) noexcept { + auto error = opus_encoder_ctl(m_encoder.get(), std::forward(args)...); + if (error != OPUS_OK) { + spdlog::get("voice")->error("Opus encoder CTL error ({}): {}", request, opus_strerror(error)); + return false; + } + + return true; +} + +} diff --git a/src/audio/voice/opus/opus_encoder.hpp b/src/audio/voice/opus/opus_encoder.hpp new file mode 100644 index 00000000..75a06467 --- /dev/null +++ b/src/audio/voice/opus/opus_encoder.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include + +#include "audio/utils.hpp" + +namespace AbaddonClient::Audio::Voice::Opus { + +using OpusOutput = Slice; + +class OpusEncoder { +public: + using EncoderPtr = std::unique_ptr<::OpusEncoder, decltype(&opus_encoder_destroy)>; + + enum class EncodingApplication { + Audio = OPUS_APPLICATION_AUDIO, + LowDelay = OPUS_APPLICATION_RESTRICTED_LOWDELAY, + VOIP = OPUS_APPLICATION_VOIP + }; + + enum class SignalHint { + Auto = OPUS_AUTO, + Voice = OPUS_SIGNAL_VOICE, + Music = OPUS_SIGNAL_MUSIC + }; + + struct EncoderSettings { + int32_t sample_rate = 48000; + int channels = 2; + int32_t bitrate = 64000; + SignalHint signal_hint = SignalHint::Auto; + EncodingApplication application = EncodingApplication::Audio; + }; + + OpusEncoder(EncoderPtr encoder, EncoderSettings settings) noexcept; + static std::optional Create(EncoderSettings settings) noexcept; + + int Encode(InputBuffer &&audio, OpusOutput &&opus, int frame_size) noexcept; + void ResetState() noexcept; + + void SetBitrate(int32_t bitrate) noexcept; + void SetSignalHint(SignalHint hint) noexcept; + void SetEncodingApplication(EncodingApplication application) noexcept; + + int32_t GetBitrate() const noexcept; + SignalHint GetSignalHint() const noexcept; + EncodingApplication GetEncodingApplication() const noexcept; +private: + template + bool OpusCTL(std::string_view request, Args&& ...args) noexcept; + + EncoderPtr m_encoder; + + const int32_t m_sample_rate; + const int m_channels; + + int32_t m_bitrate; + SignalHint m_signal_hint; + EncodingApplication m_application; +}; + +} diff --git a/src/audio/voice/peak_meter/peak_meter.cpp b/src/audio/voice/peak_meter/peak_meter.cpp new file mode 100644 index 00000000..af436325 --- /dev/null +++ b/src/audio/voice/peak_meter/peak_meter.cpp @@ -0,0 +1,29 @@ +#include "peak_meter.hpp" + +namespace AbaddonClient::Audio::Voice { + +void PeakMeter::UpdatePeak(InputBuffer buffer) noexcept { + // Cache to prevent atomic operations in the loop + float peak = m_peak; + + for (const auto& sample: buffer) { + peak = std::max(peak, std::abs(sample)); + } + + m_peak = peak; +} + +void PeakMeter::Decay() noexcept { + m_peak = std::max(m_peak - 0.05f, 0.0f); +} + +void PeakMeter::SetPeak(float peak) noexcept { + m_peak = std::max(m_peak.load(), peak); +} + +float PeakMeter::GetPeak() const noexcept { + return m_peak; +} + + +} diff --git a/src/audio/voice/peak_meter/peak_meter.hpp b/src/audio/voice/peak_meter/peak_meter.hpp new file mode 100644 index 00000000..adc3cdbc --- /dev/null +++ b/src/audio/voice/peak_meter/peak_meter.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include "audio/utils.hpp" + +namespace AbaddonClient::Audio::Voice { + +class PeakMeter { +public: + void UpdatePeak(InputBuffer buffer) noexcept; + void Decay() noexcept; + + void SetPeak(float peak) noexcept; + float GetPeak() const noexcept; +private: + std::atomic m_peak = 0; +}; + +}; diff --git a/src/audio/voice/playback/client.cpp b/src/audio/voice/playback/client.cpp new file mode 100644 index 00000000..2e71576b --- /dev/null +++ b/src/audio/voice/playback/client.cpp @@ -0,0 +1,60 @@ +#include "client.hpp" + +namespace AbaddonClient::Audio::Voice::Playback { + +Client::Client(Opus::OpusDecoder &&decoder, VoiceBuffer &&buffer, DecodePool &decode_pool) noexcept : + m_decoder( std::make_shared< Mutex >(std::move(decoder)) ), + m_buffer( std::make_shared( std::move(buffer) )), + m_decode_pool(decode_pool) {} + +void Client::DecodeFromRTP(const std::vector &&rtp) noexcept { + if (m_muted) { + return; + } + + auto decode_data = DecodePool::DecodeData { + std::move(rtp), + m_decoder, + m_buffer, + }; + + m_decode_pool.DecodeFromRTP(std::move(decode_data)); +} + +void Client::WriteAudio(OutputBuffer buffer) noexcept { + if (m_muted) { + return; + } + + m_buffer->Read(buffer); + AudioUtils::ApplyGain(buffer, m_volume); + + m_peak_meter.UpdatePeak(buffer); +} + +void Client::ClearBuffer() noexcept { + m_buffer->Clear(); +} + +void Client::SetMuted(bool muted) noexcept { + if (muted) { + // Clear the buffer to prevent residue samples playing back later + ClearBuffer(); + } + + m_muted = muted; +} + +bool Client::GetMuted() const noexcept { + return m_muted; +} + +PeakMeter& Client::GetPeakMeter() noexcept { + return m_peak_meter; +} + +const PeakMeter& Client::GetPeakMeter() const noexcept { + return m_peak_meter; +} + +} diff --git a/src/audio/voice/playback/client.hpp b/src/audio/voice/playback/client.hpp new file mode 100644 index 00000000..4cbe99c0 --- /dev/null +++ b/src/audio/voice/playback/client.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include "audio/voice/opus/opus_decoder.hpp" +#include "audio/voice/peak_meter/peak_meter.hpp" + +#include "decode_pool.hpp" + +namespace AbaddonClient::Audio::Voice::Playback { + +class Client { +public: + Client(Opus::OpusDecoder &&decoder, VoiceBuffer &&buffer, DecodePool &decode_pool) noexcept; + + void DecodeFromRTP(const std::vector &&rtp) noexcept; + void WriteAudio(OutputBuffer output) noexcept; + + void ClearBuffer() noexcept; + + void SetMuted(bool muted) noexcept; + bool GetMuted() const noexcept; + + PeakMeter& GetPeakMeter() noexcept; + const PeakMeter& GetPeakMeter() const noexcept; + + std::atomic m_volume = 1.0; + +private: + SharedDecoder m_decoder; + SharedBuffer m_buffer; + + DecodePool &m_decode_pool; + PeakMeter m_peak_meter; + + std::atomic m_muted = false; +}; + +} diff --git a/src/audio/voice/playback/client_store.cpp b/src/audio/voice/playback/client_store.cpp new file mode 100644 index 00000000..81ea91ce --- /dev/null +++ b/src/audio/voice/playback/client_store.cpp @@ -0,0 +1,149 @@ +#include "client_store.hpp" +#include "constants.hpp" + +namespace AbaddonClient::Audio::Voice::Playback { + +void ClientStore::AddClient(ClientID id) noexcept { + auto clients = m_clients.Lock(); + + if (clients->find(id) != clients->end()) { + spdlog::get("voice")->error("Tried to add an existing client: {}", id); + return; + }; + + auto settings = Opus::OpusDecoder::DecoderSettings {}; + settings.sample_rate = RTP_SAMPLE_RATE; + settings.channels = RTP_CHANNELS; + + auto decoder = Opus::OpusDecoder::Create(settings); + if (!decoder) { + return; + } + + auto buffer = VoiceBuffer::Create(RTP_CHANNELS, RTP_OPUS_FRAME_SIZE * 4, 1024); + if (!buffer) { + return; + } + + m_decode_pool.AddDecoder(); + + clients->emplace( + std::piecewise_construct, + std::forward_as_tuple(id), + std::forward_as_tuple(std::move(*decoder), std::move(*buffer), m_decode_pool)); +} + +void ClientStore::RemoveClient(ClientID id) noexcept { + auto clients = m_clients.Lock(); + clients->erase(id); + + // Keep the decoder count below client count + // Doesn't make sense to have more + if (m_decode_pool.GetDecoderCount() > clients->size()) { + m_decode_pool.RemoveDecoder(); + } +} + +void ClientStore::Clear() noexcept { + m_decode_pool.ClearDecoders(); + m_clients.Lock()->clear(); +} + +void ClientStore::DecodeFromRTP(ClientID id, const std::vector &&data) noexcept { + auto clients = m_clients.Lock(); + auto client = clients->find(id); + + if (client == clients->end()) { + spdlog::get("voice")->error("Tried to decode Opus data for missing client: {}", id); + return; + } + + client->second.DecodeFromRTP(std::move(data)); +} + +void ClientStore::WriteMixed(OutputBuffer buffer) noexcept { + auto clients = m_clients.Lock(); + + // Reusing per client buffer, + // miniaudio doesn't provide a way to know the maximum buffer size in advance + // so we have to resize it dynamically. + // This shouldn't have a big performance hit as the capacity will settle on some value eventually + + for (auto& [it, client] : clients) { + m_client_buffer.resize(buffer.size()); + + client.WriteAudio(m_client_buffer); + AudioUtils::MixBuffers(m_client_buffer, buffer); + + m_client_buffer.clear(); + } +} + +void ClientStore::DecayPeakMeters() noexcept { + auto clients = m_clients.Lock(); + + for (auto& [_, client] : clients) { + client.GetPeakMeter().Decay(); + } +} + +void ClientStore::SetClientVolume(const ClientID id, const float volume) noexcept { + auto clients = m_clients.Lock(); + auto client = clients->find(id); + + if (client != clients->end()) { + client->second.m_volume = volume; + } +} + +void ClientStore::ClearAllBuffers() noexcept { + auto clients = m_clients.Lock(); + + for (auto& [_, client] : clients) { + client.ClearBuffer(); + } +} + +void ClientStore::SetClientMute(const ClientID id, const bool muted) noexcept { + auto clients = m_clients.Lock(); + auto client = clients->find(id); + + if (client != clients->end()) { + client->second.SetMuted(muted); + } +} + +float ClientStore::GetClientVolume(const ClientID id) const noexcept { + const auto clients = m_clients.Lock(); + const auto client = clients->find(id); + + if (client != clients->end()) { + return client->second.m_volume; + } + + return 1.0; +} + +bool ClientStore::GetClientMute(const ClientID id) const noexcept { + const auto clients = m_clients.Lock(); + const auto client = clients->find(id); + + if (client != clients->end()) { + return client->second.GetMuted(); + } + + return false; +} + +float ClientStore::GetClientPeakVolume(const ClientID id) const noexcept { + const auto clients = m_clients.Lock(); + const auto client = clients->find(id); + + if (client != clients->end()) { + return client->second.GetPeakMeter().GetPeak(); + } + + return 0.0f; +} + +} diff --git a/src/audio/voice/playback/client_store.hpp b/src/audio/voice/playback/client_store.hpp new file mode 100644 index 00000000..0f441021 --- /dev/null +++ b/src/audio/voice/playback/client_store.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include "audio/utils.hpp" + +#include "client.hpp" +#include "decode_pool.hpp" + +// This needs to be forward declared for friend declaration +namespace AbaddonClient::Audio::Voice { + class VoicePlayback; +} + +namespace AbaddonClient::Audio::Voice::Playback { + + +class ClientStore { +public: + using ClientID = uint32_t; + + void AddClient(const ClientID id) noexcept; + void RemoveClient(const ClientID id) noexcept; + void Clear() noexcept; + + void DecayPeakMeters() noexcept; + void ClearAllBuffers() noexcept; + + void SetClientVolume(const ClientID id, const float volume) noexcept; + void SetClientMute(const ClientID id, const bool muted) noexcept; + + float GetClientVolume(const ClientID id) const noexcept; + bool GetClientMute(const ClientID id) const noexcept; + + float GetClientPeakVolume(const ClientID id) const noexcept; + +private: + using ClientMap = std::unordered_map; + + // Keep these two private and expose through VoicePlayback + void DecodeFromRTP(const ClientID id, const std::vector &&data) noexcept; + void WriteMixed(OutputBuffer buffer) noexcept; + + DecodePool m_decode_pool; + Mutex m_clients; + + std::vector m_client_buffer; + + friend Voice::VoicePlayback; +}; + +} diff --git a/src/audio/voice/playback/constants.hpp b/src/audio/voice/playback/constants.hpp new file mode 100644 index 00000000..5d0199e2 --- /dev/null +++ b/src/audio/voice/playback/constants.hpp @@ -0,0 +1,9 @@ +#pragma once + +#include + +constexpr ma_format RTP_FORMAT = ma_format_f32; +constexpr uint32_t RTP_SAMPLE_RATE = 48000; +constexpr uint32_t RTP_CHANNELS = 2; +constexpr uint32_t RTP_OPUS_FRAME_SIZE = 5760; +constexpr uint32_t RTP_OPUS_MAX_BUFFER_SIZE = RTP_OPUS_FRAME_SIZE * RTP_CHANNELS; diff --git a/src/audio/voice/playback/decode_pool.cpp b/src/audio/voice/playback/decode_pool.cpp new file mode 100644 index 00000000..311f2aca --- /dev/null +++ b/src/audio/voice/playback/decode_pool.cpp @@ -0,0 +1,75 @@ +#include "decode_pool.hpp" +#include "constants.hpp" + +namespace AbaddonClient::Audio::Voice::Playback { + +// I have no idea what this does, I just copied it from AudioManager +Opus::OpusInput StripRTPExtensionHeader(ConstSlice rtp) { + if (rtp[0] == 0xbe && rtp[1] == 0xde && rtp.size() > 4) { + uint64_t offset = 4 + 4 * ((rtp[2] << 8) | rtp[3]); + + return Opus::OpusInput(rtp.data() + offset, rtp.size() - offset); + } + return rtp; +} + +DecodePool::DecodePool() noexcept : + m_pool(Pool(&DecodePool::DecodeThread, 20)) +{ + auto concurrency = std::thread::hardware_concurrency() / 2; + if (concurrency == 0) { + concurrency = 2; + } + + m_pool.m_max_threads = concurrency; +} + +void DecodePool::DecodeFromRTP(DecodeData &&decode_data) noexcept { + m_pool.SendToPool(std::move(decode_data)); +} + +void DecodePool::AddDecoder() noexcept { + m_pool.AddThread(); +} + +void DecodePool::RemoveDecoder() noexcept { + m_pool.RemoveThread(); +} + +void DecodePool::ClearDecoders() noexcept { + m_pool.Clear(); +} + +size_t DecodePool::GetDecoderCount() const noexcept { + return m_pool.GetThreadCount(); +} + +void DecodePool::DecodeThread(Channel &channel) noexcept { + while (true) { + auto message = channel.Recv(); + if (std::holds_alternative(message)) { + break; + } + + auto&& decode_message = std::get(message); + OnDecodeMessage(std::move(decode_message)); + } +} + +void DecodePool::OnDecodeMessage(DecodeData &&message) noexcept { + auto&& [rtp, decoder, buffer] = message; + + static std::array pcm; + const auto opus = StripRTPExtensionHeader(rtp); + + int frames = decoder->Lock()->Decode(opus, pcm, RTP_OPUS_FRAME_SIZE); + if (frames < 0) { + return; + } + + // Take only what we've got from the decoder + auto pcm_written = OutputBuffer(pcm.data(), frames * RTP_CHANNELS); + buffer->Write(pcm_written); +} + +} diff --git a/src/audio/voice/playback/decode_pool.hpp b/src/audio/voice/playback/decode_pool.hpp new file mode 100644 index 00000000..cf5fc0d8 --- /dev/null +++ b/src/audio/voice/playback/decode_pool.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include "audio/voice/opus/opus_decoder.hpp" +#include "voice_buffer.hpp" + +#include "misc/threadpool.hpp" + +namespace AbaddonClient::Audio::Voice::Playback { + +using SharedDecoder = std::shared_ptr>; +using SharedBuffer = std::shared_ptr; + +class DecodePool { +public: + DecodePool() noexcept; + + struct DecodeData { + const std::vector rtp; + + SharedDecoder decoder; + SharedBuffer buffer; + }; + + void DecodeFromRTP(DecodeData &&decode_data) noexcept; + + void AddDecoder() noexcept; + void RemoveDecoder() noexcept; + void ClearDecoders() noexcept; + + size_t GetDecoderCount() const noexcept; + +private: + static void DecodeThread(Channel> &channel) noexcept; + static void OnDecodeMessage(DecodeData &&decode_data) noexcept; + + using Pool = ThreadPool; + + Pool m_pool; +}; + +} diff --git a/src/audio/voice/playback/voice_buffer.cpp b/src/audio/voice/playback/voice_buffer.cpp new file mode 100644 index 00000000..8ecc8c3a --- /dev/null +++ b/src/audio/voice/playback/voice_buffer.cpp @@ -0,0 +1,83 @@ +#include "voice_buffer.hpp" + +namespace AbaddonClient::Audio::Voice::Playback { + +VoiceBuffer::VoiceBuffer(RingBufferPtr &&ringbuffer, uint32_t channels, uint32_t buffer_frames) noexcept : + m_ringbuffer(std::move(ringbuffer)), + m_channels(channels), + m_buffer_frames(buffer_frames) {} + +std::optional VoiceBuffer::Create(uint32_t channels, uint32_t buffer_size_in_frames, uint32_t buffer_frames) noexcept { + auto ringbuffer_ptr = RingBufferPtr(new ma_pcm_rb, &ma_pcm_rb_uninit); + + const auto result = ma_pcm_rb_init(ma_format_f32, channels, buffer_size_in_frames, nullptr, nullptr, ringbuffer_ptr.get()); + if (result != MA_SUCCESS) { + spdlog::get("voice")->error("Failed to create voice buffer: {}", ma_result_description(result)); + return std::nullopt; + } + + return std::make_optional(std::move(ringbuffer_ptr), channels, buffer_frames); +} + +void VoiceBuffer::Read(OutputBuffer output) noexcept { + const auto total_frames = output.size() / m_channels; + const auto available_frames = ma_pcm_rb_available_read(m_ringbuffer.get()); + + // Make sure to always leave some distance + if (available_frames < m_buffer_frames) { + return; + } + + uint32_t read = 0; + uint32_t tries = 0; + + // Try twice in case of wrap around + while (read < total_frames && tries < 2) { + uint32_t frames = total_frames - read; + float* read_ptr; + + ma_pcm_rb_acquire_read(m_ringbuffer.get(), &frames, reinterpret_cast(&read_ptr)); + + const auto start = read_ptr; + const auto end = start + frames * m_channels + 1; + const auto result = output.begin() + read * m_channels; + + std::copy(start, end, result); + + ma_pcm_rb_commit_read(m_ringbuffer.get(), frames); + + read += frames; + tries++; + } +} + +void VoiceBuffer::Write(InputBuffer input) noexcept { + const auto total_frames = input.size() / m_channels; + + uint32_t written = 0; + uint32_t tries = 0; + + // Try twice in case of wrap around + while (written < total_frames && tries < 2) { + uint32_t frames = total_frames - written; + float* write_ptr; + + ma_pcm_rb_acquire_write(m_ringbuffer.get(), &frames, reinterpret_cast(&write_ptr)); + + const auto start = input.begin() + written * m_channels; + const auto end = start + frames * m_channels + 1; + + std::copy(start, end, write_ptr); + + ma_pcm_rb_commit_write(m_ringbuffer.get(), frames); + + written += frames; + tries++; + } +} + +void VoiceBuffer::Clear() noexcept { + ma_pcm_rb_reset(m_ringbuffer.get()); +} + +} diff --git a/src/audio/voice/playback/voice_buffer.hpp b/src/audio/voice/playback/voice_buffer.hpp new file mode 100644 index 00000000..cf6b2b3b --- /dev/null +++ b/src/audio/voice/playback/voice_buffer.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include + +namespace AbaddonClient::Audio::Voice::Playback { + +class VoiceBuffer { +public: + using RingBufferPtr = std::unique_ptr; + + VoiceBuffer(RingBufferPtr &&ringbuffer, uint32_t channels, uint32_t buffer_frames) noexcept; + static std::optional Create(uint32_t channels, uint32_t buffer_size_in_frames, uint32_t buffer_frames) noexcept; + + void Read(OutputBuffer output) noexcept; + void Write(InputBuffer input) noexcept; + void Clear() noexcept; + +private: + + + RingBufferPtr m_ringbuffer; + + uint32_t m_channels; + uint32_t m_buffer_frames; + + bool moved = false; +}; + +} diff --git a/src/audio/voice/playback/voice_playback.cpp b/src/audio/voice/playback/voice_playback.cpp new file mode 100644 index 00000000..0b16ae91 --- /dev/null +++ b/src/audio/voice/playback/voice_playback.cpp @@ -0,0 +1,83 @@ +#include "voice_playback.hpp" +#include "constants.hpp" + +namespace AbaddonClient::Audio::Voice { + +void playback_callback(ma_device* pDevice, void* pOutput, const void* pInput, ma_uint32 frameCount) { + auto playback = reinterpret_cast(pDevice->pUserData); + if (playback == nullptr) { + return; + } + + auto buffer = OutputBuffer(static_cast(pOutput), frameCount * RTP_CHANNELS); + playback->OnAudioPlayback(buffer); +} + +VoicePlayback::VoicePlayback(Context &context) noexcept : + m_device(context, GetDeviceConfig(), context.GetActivePlaybackID()) {} + +void VoicePlayback::OnRTPData(ClientID id, const std::vector &&data) noexcept { + if (m_active) { + m_clients.DecodeFromRTP(id, std::move(data)); + } +} + +void VoicePlayback::OnAudioPlayback(OutputBuffer buffer) noexcept { + if (m_active) { + m_clients.WriteMixed(buffer); + AudioUtils::ClampToFloatRange(buffer); // Clamp it at the end + } +} + +void VoicePlayback::Start() noexcept { + m_device.Start(); +} + +void VoicePlayback::Stop() noexcept { + m_device.Stop(); +} + +void VoicePlayback::SetActive(bool active) noexcept { + if (!active) { + // Clear all buffers to prevent residue samples playing back later + m_clients.ClearAllBuffers(); + } + + m_active = active; +} + +void VoicePlayback::SetPlaybackDevice(ma_device_id &&device_id) noexcept { + spdlog::get("voice")->info("Setting playback device"); + + const auto success = m_device.ChangeDevice(std::move(device_id)); + if (!success) { + spdlog::get("voice")->error("Failed to set playback device"); + } +} + +Playback::ClientStore& VoicePlayback::GetClientStore() noexcept { + return m_clients; +} + +const Playback::ClientStore& VoicePlayback::GetClientStore() const noexcept { + return m_clients; +} + +ma_device_config VoicePlayback::GetDeviceConfig() noexcept { + auto config = ma_device_config_init(ma_device_type_playback); + config.playback.format = RTP_FORMAT; + config.sampleRate = RTP_SAMPLE_RATE; + config.playback.channels = RTP_CHANNELS; + config.pulse.pStreamNamePlayback = "Abaddon (Voice)"; + + config.noClip = true; + config.noFixedSizedCallback = true; + config.performanceProfile = ma_performance_profile_low_latency; + + config.dataCallback = playback_callback; + config.pUserData = this; + + return config; +} + +} diff --git a/src/audio/voice/playback/voice_playback.hpp b/src/audio/voice/playback/voice_playback.hpp new file mode 100644 index 00000000..cd2a69e3 --- /dev/null +++ b/src/audio/voice/playback/voice_playback.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include "audio/audio_device.hpp" +#include "audio/context.hpp" +#include "audio/utils.hpp" + +#include "client_store.hpp" + +namespace AbaddonClient::Audio::Voice { + +class VoicePlayback { +public: + using ClientID = Playback::ClientStore::ClientID; + + VoicePlayback(Context &context) noexcept; + + void OnRTPData(ClientID id, const std::vector &&data) noexcept; + + void Start() noexcept; + void Stop() noexcept; + + void SetActive(bool active) noexcept; + void SetPlaybackDevice(ma_device_id &&device_id) noexcept; + + Playback::ClientStore& GetClientStore() noexcept; + const Playback::ClientStore& GetClientStore() const noexcept; + +private: + void OnAudioPlayback(OutputBuffer buffer) noexcept; + ma_device_config GetDeviceConfig() noexcept; + + AudioDevice m_device; + Playback::ClientStore m_clients; + + std::atomic m_active = true; + + friend void playback_callback(ma_device* pDevice, void* pOutput, const void* pInput, ma_uint32 frameCount); +}; + +}; diff --git a/src/audio/voice/voice_audio.cpp b/src/audio/voice/voice_audio.cpp new file mode 100644 index 00000000..032e766b --- /dev/null +++ b/src/audio/voice/voice_audio.cpp @@ -0,0 +1,38 @@ +#include "voice_audio.hpp" + +namespace AbaddonClient::Audio { + +using VoicePlayback = Voice::VoicePlayback; +using VoiceCapture = Voice::VoiceCapture; + +VoiceAudio::VoiceAudio(Context &context) noexcept : + m_playback(context), + m_capture(context) {} + +void VoiceAudio::Start() noexcept { + m_playback.Start(); + m_capture.Start(); +} + +void VoiceAudio::Stop() noexcept { + m_playback.Stop(); + m_capture.Stop(); +} + +VoicePlayback& VoiceAudio::GetPlayback() noexcept { + return m_playback; +} + +const VoicePlayback& VoiceAudio::GetPlayback() const noexcept { + return m_playback; +} + +VoiceCapture& VoiceAudio::GetCapture() noexcept { + return m_capture; +} + +const VoiceCapture& VoiceAudio::GetCapture() const noexcept { + return m_capture; +} + +} diff --git a/src/audio/voice/voice_audio.hpp b/src/audio/voice/voice_audio.hpp new file mode 100644 index 00000000..9fffd22f --- /dev/null +++ b/src/audio/voice/voice_audio.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include "audio/context.hpp" + +#include "capture/voice_capture.hpp" +#include "playback/voice_playback.hpp" + +namespace AbaddonClient::Audio { + +class VoiceAudio { +public: + using VoicePlayback = Voice::VoicePlayback; + using VoiceCapture = Voice::VoiceCapture; + + VoiceAudio(Context &context) noexcept; + + void Start() noexcept; + void Stop() noexcept; + + VoicePlayback& GetPlayback() noexcept; + const VoicePlayback& GetPlayback() const noexcept; + + VoiceCapture& GetCapture() noexcept; + const VoiceCapture& GetCapture() const noexcept; + +private: + VoiceCapture m_capture; + VoicePlayback m_playback; +}; + +} diff --git a/src/discord/voiceclient.cpp b/src/discord/voiceclient.cpp index e4b56bda..e74ac0cd 100644 --- a/src/discord/voiceclient.cpp +++ b/src/discord/voiceclient.cpp @@ -154,9 +154,9 @@ DiscordVoiceClient::DiscordVoiceClient() Glib::signal_idle().connect_once([this]() { auto &audio = Abaddon::Get().GetAudio(); audio.SetOpusBuffer(m_opus_buffer.data()); - audio.signal_opus_packet().connect([this](int payload_size) { + audio.signal_opus_packet().connect([this](const std::vector opus) { if (IsConnected()) { - m_udp.SendEncrypted(m_opus_buffer.data(), payload_size); + m_udp.SendEncrypted(opus.data(), opus.size()); } }); }); From 39011b6d90e6e38aa4a591854d444a5dc279ee04 Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Mon, 13 May 2024 02:54:42 +0300 Subject: [PATCH 09/39] Fix typo: u_int8_t -> uint8_t --- src/audio/voice/playback/client.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/audio/voice/playback/client.hpp b/src/audio/voice/playback/client.hpp index 4cbe99c0..71cf7e1b 100644 --- a/src/audio/voice/playback/client.hpp +++ b/src/audio/voice/playback/client.hpp @@ -11,7 +11,7 @@ class Client { public: Client(Opus::OpusDecoder &&decoder, VoiceBuffer &&buffer, DecodePool &decode_pool) noexcept; - void DecodeFromRTP(const std::vector &&rtp) noexcept; + void DecodeFromRTP(const std::vector &&rtp) noexcept; void WriteAudio(OutputBuffer output) noexcept; void ClearBuffer() noexcept; From 145057f18256ef2a8365bb178a1c41f15ca5c225 Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Mon, 13 May 2024 03:18:54 +0300 Subject: [PATCH 10/39] Make constructors public for in-place constructor to work --- src/audio/context.hpp | 2 +- src/audio/miniaudio/ma_context.hpp | 12 ++++++------ src/audio/miniaudio/ma_device.hpp | 13 ++++++------- src/audio/voice/playback/voice_buffer.hpp | 2 -- 4 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/audio/context.hpp b/src/audio/context.hpp index a9c18108..456e03d2 100644 --- a/src/audio/context.hpp +++ b/src/audio/context.hpp @@ -6,6 +6,7 @@ namespace AbaddonClient::Audio { class Context { public: + Context(Miniaudio::MaContext &&context) noexcept; static std::optional Create(ma_context_config &&config, ConstSlice backends) noexcept; ConstSlice GetPlaybackDevices() noexcept; @@ -17,7 +18,6 @@ class Context { Miniaudio::MaContext& GetRaw() noexcept; private: - Context(Miniaudio::MaContext &&context) noexcept; void PopulateDevices() noexcept; void FindDefaultDevices() noexcept; diff --git a/src/audio/miniaudio/ma_context.hpp b/src/audio/miniaudio/ma_context.hpp index bf69b20b..85bef058 100644 --- a/src/audio/miniaudio/ma_context.hpp +++ b/src/audio/miniaudio/ma_context.hpp @@ -12,6 +12,12 @@ class MaContext { using CaptureDeviceInfo = ConstSlice; using DeviceInfo = std::pair; + // Put ma_context behind pointer to allow moving. + // miniaudio expects ma_context reference to be valid at all times + // Moving it to other location would cause memory corruption + using ContextPtr = std::unique_ptr; + + MaContext(ContextPtr &&context) noexcept; static std::optional Create(ma_context_config &&config, ConstSlice backends) noexcept; std::optional GetDevices() noexcept; @@ -19,12 +25,6 @@ class MaContext { ma_context& GetInternal() noexcept; private: - // Put ma_context behind pointer to allow moving. - // miniaudio expects ma_context reference to be valid at all times - // Moving it to other location would cause memory corruption - using ContextPtr = std::unique_ptr; - MaContext(ContextPtr &&context) noexcept; - ContextPtr m_context; }; diff --git a/src/audio/miniaudio/ma_device.hpp b/src/audio/miniaudio/ma_device.hpp index 4c891b7c..75bac295 100644 --- a/src/audio/miniaudio/ma_device.hpp +++ b/src/audio/miniaudio/ma_device.hpp @@ -8,6 +8,12 @@ namespace AbaddonClient::Audio::Miniaudio { class MaDevice { public: + // Put ma_device behind pointer to allow moving + // miniaudio expects ma_device reference to be valid at all times. + // Moving it to other location would cause memory corruption + using DevicePtr = std::unique_ptr; + MaDevice(DevicePtr &&device) noexcept; + static std::optional Create(MaContext &context, ma_device_config &config) noexcept; bool Start() noexcept; @@ -16,13 +22,6 @@ class MaDevice { ma_device& GetInternal() noexcept; private: - // Put ma_device behind pointer to allow moving - // miniaudio expects ma_device reference to be valid at all times. - - // Moving it to other location would cause memory corruption - using DevicePtr = std::unique_ptr; - MaDevice(DevicePtr &&device) noexcept; - DevicePtr m_device; }; diff --git a/src/audio/voice/playback/voice_buffer.hpp b/src/audio/voice/playback/voice_buffer.hpp index cf6b2b3b..1f83c765 100644 --- a/src/audio/voice/playback/voice_buffer.hpp +++ b/src/audio/voice/playback/voice_buffer.hpp @@ -16,8 +16,6 @@ class VoiceBuffer { void Clear() noexcept; private: - - RingBufferPtr m_ringbuffer; uint32_t m_channels; From f4679b44f2bcf1a9a520af49b4f76939242d0828 Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Tue, 21 May 2024 16:33:09 +0300 Subject: [PATCH 11/39] Apply suggestions --- src/audio/audio_device.cpp | 4 +-- src/audio/audio_device.hpp | 2 +- src/audio/context.cpp | 2 +- src/audio/devices.cpp | 2 +- src/audio/devices.hpp | 2 +- src/audio/manager.cpp | 31 ++++++++++----------- src/audio/manager.hpp | 2 +- src/audio/miniaudio/ma_context.cpp | 4 +-- src/audio/miniaudio/ma_device.cpp | 2 +- src/audio/utils.hpp | 2 +- src/audio/voice/capture/effects/gate.cpp | 2 +- src/audio/voice/capture/effects/gate.hpp | 2 +- src/audio/voice/capture/effects/noise.cpp | 8 +++--- src/audio/voice/capture/effects/noise.hpp | 6 ++-- src/audio/voice/capture/voice_capture.cpp | 20 ++++++------- src/audio/voice/capture/voice_capture.hpp | 12 ++++---- src/audio/voice/opus/opus_decoder.cpp | 2 +- src/audio/voice/opus/opus_encoder.cpp | 13 ++++++--- src/audio/voice/playback/client.cpp | 4 +-- src/audio/voice/playback/client.hpp | 4 +-- src/audio/voice/playback/client_store.cpp | 18 ++++++------ src/audio/voice/playback/client_store.hpp | 16 +++++------ src/audio/voice/playback/decode_pool.cpp | 12 ++------ src/audio/voice/playback/decode_pool.hpp | 2 +- src/audio/voice/playback/voice_buffer.cpp | 2 +- src/audio/voice/playback/voice_playback.cpp | 8 +++--- src/audio/voice/playback/voice_playback.hpp | 4 +-- src/misc/mutex.hpp | 1 - src/misc/threadpool.hpp | 17 +++++++---- 29 files changed, 104 insertions(+), 102 deletions(-) diff --git a/src/audio/audio_device.cpp b/src/audio/audio_device.cpp index 30a64528..b7a17b4d 100644 --- a/src/audio/audio_device.cpp +++ b/src/audio/audio_device.cpp @@ -44,8 +44,8 @@ bool AudioDevice::Stop() noexcept { return true; } -bool AudioDevice::ChangeDevice(ma_device_id &&device_id) noexcept { - m_device_id = std::move(device_id); +bool AudioDevice::ChangeDevice(const ma_device_id &device_id) noexcept { + m_device_id = device_id; return RefreshDevice(); } diff --git a/src/audio/audio_device.hpp b/src/audio/audio_device.hpp index c9e4ac23..42877a24 100644 --- a/src/audio/audio_device.hpp +++ b/src/audio/audio_device.hpp @@ -13,7 +13,7 @@ class AudioDevice { bool Start() noexcept; bool Stop() noexcept; - bool ChangeDevice(ma_device_id &&device_id) noexcept; + bool ChangeDevice(const ma_device_id &device_id) noexcept; private: void SyncDeviceID() noexcept; bool RefreshDevice() noexcept; diff --git a/src/audio/context.cpp b/src/audio/context.cpp index 5c4be23e..39c46290 100644 --- a/src/audio/context.cpp +++ b/src/audio/context.cpp @@ -15,7 +15,7 @@ std::optional Context::Create(ma_context_config &&config, ConstSlice(std::move(*context)); + return std::move(*context); } diff --git a/src/audio/devices.cpp b/src/audio/devices.cpp index ad34030d..bff013f9 100644 --- a/src/audio/devices.cpp +++ b/src/audio/devices.cpp @@ -21,7 +21,7 @@ Glib::RefPtr AudioDevices::GetCaptureDeviceModel() const { return m_capture; } -void AudioDevices::SetDevices(ma_device_info *pPlayback, ma_uint32 playback_count, ma_device_info *pCapture, ma_uint32 capture_count) { +void AudioDevices::SetDevices(const ma_device_info *pPlayback, ma_uint32 playback_count, const ma_device_info *pCapture, ma_uint32 capture_count) { m_playback->clear(); for (ma_uint32 i = 0; i < playback_count; i++) { diff --git a/src/audio/devices.hpp b/src/audio/devices.hpp index 18ef0768..d602cb62 100644 --- a/src/audio/devices.hpp +++ b/src/audio/devices.hpp @@ -13,7 +13,7 @@ class AudioDevices { public: AudioDevices(); - void SetDevices(ma_device_info *pPlayback, ma_uint32 playback_count, ma_device_info *pCapture, ma_uint32 capture_count); + void SetDevices(const ma_device_info *pPlayback, ma_uint32 playback_count, const ma_device_info *pCapture, ma_uint32 capture_count); [[nodiscard]] std::optional GetPlaybackDeviceIDFromModel(const Gtk::TreeModel::iterator &iter) const; [[nodiscard]] std::optional GetCaptureDeviceIDFromModel(const Gtk::TreeModel::iterator &iter) const; diff --git a/src/audio/manager.cpp b/src/audio/manager.cpp index e8ec66b7..76c4ae30 100644 --- a/src/audio/manager.cpp +++ b/src/audio/manager.cpp @@ -170,7 +170,7 @@ void AudioManager::SetOpusBuffer(uint8_t *ptr) { m_opus_buffer = ptr; } -void AudioManager::FeedMeOpus(uint32_t ssrc, const std::vector &data) { +void AudioManager::FeedMeOpus(uint32_t ssrc, std::vector &&data) { m_voice->GetPlayback().OnRTPData(ssrc, std::move(data)); } @@ -182,7 +182,7 @@ void AudioManager::SetPlaybackDevice(const Gtk::TreeModel::iterator &iter) { return; } - m_voice->GetPlayback().SetPlaybackDevice(std::move(*device_id)); + m_voice->GetPlayback().SetPlaybackDevice(*device_id); } void AudioManager::SetCaptureDevice(const Gtk::TreeModel::iterator &iter) { @@ -192,7 +192,7 @@ void AudioManager::SetCaptureDevice(const Gtk::TreeModel::iterator &iter) { return; } - m_voice->GetCapture().SetCaptureDevice(std::move(*device_id)); + m_voice->GetCapture().SetCaptureDevice(*device_id); } void AudioManager::SetCapture(bool capture) { @@ -204,19 +204,19 @@ void AudioManager::SetPlayback(bool playback) { } void AudioManager::SetCaptureGate(double gate) { - m_voice->GetCapture().GetEffects().GetGate().m_vad_threshold = gate; + m_voice->GetCapture().GetEffects().GetGate().VADThreshold = gate; } void AudioManager::SetCaptureGain(double gain) { - m_voice->GetCapture().m_gain = gain; + m_voice->GetCapture().Gain = gain; } double AudioManager::GetCaptureGate() const noexcept { - return m_voice->GetCapture().GetEffects().GetGate().m_vad_threshold; + return m_voice->GetCapture().GetEffects().GetGate().VADThreshold; } double AudioManager::GetCaptureGain() const noexcept { - return m_voice->GetCapture().m_gain; + return m_voice->GetCapture().Gain; } void AudioManager::SetMuteSSRC(uint32_t ssrc, bool mute) { @@ -272,11 +272,10 @@ void AudioManager::Enumerate() { spdlog::get("audio")->info("Found {} playback devices and {} capture devices", playback_devices.size(), capture_devices.size()); - // I don't know why this does not accept const m_devices.SetDevices( - const_cast(playback_devices.data()), + playback_devices.data(), playback_devices.size(), - const_cast(capture_devices.data()), + capture_devices.data(), capture_devices.size() ); } @@ -487,28 +486,28 @@ float AudioManager::GetCurrentVADProbability() const { } double AudioManager::GetRNNProbThreshold() const { - return m_voice->GetCapture().GetEffects().GetNoise().m_vad_threshold; + return m_voice->GetCapture().GetEffects().GetNoise().VADThreshold; } void AudioManager::SetRNNProbThreshold(double value) { - m_voice->GetCapture().GetEffects().GetNoise().m_vad_threshold = value; + m_voice->GetCapture().GetEffects().GetNoise().VADThreshold = value; } void AudioManager::SetSuppressNoise(bool value) { - m_voice->GetCapture().m_suppress_noise = value; + m_voice->GetCapture().SuppressNoise = value; } bool AudioManager::GetSuppressNoise() const { - return m_voice->GetCapture().m_suppress_noise; + return m_voice->GetCapture().SuppressNoise; } #endif void AudioManager::SetMixMono(bool value) { - m_voice->GetCapture().m_mix_mono = value; + m_voice->GetCapture().MixMono = value; } bool AudioManager::GetMixMono() const { - return m_voice->GetCapture().m_mix_mono; + return m_voice->GetCapture().MixMono; } AbaddonClient::Audio::Voice::VoiceCapture::CaptureSignal AudioManager::signal_opus_packet() { diff --git a/src/audio/manager.hpp b/src/audio/manager.hpp index 64c56682..d9d32877 100644 --- a/src/audio/manager.hpp +++ b/src/audio/manager.hpp @@ -38,7 +38,7 @@ class AudioManager { void RemoveAllSSRCs(); void SetOpusBuffer(uint8_t *ptr); - void FeedMeOpus(uint32_t ssrc, const std::vector &data); + void FeedMeOpus(uint32_t ssrc, std::vector &&data); void StartVoice(); void StopVoice(); diff --git a/src/audio/miniaudio/ma_context.cpp b/src/audio/miniaudio/ma_context.cpp index a9ca5418..6b01b133 100644 --- a/src/audio/miniaudio/ma_context.cpp +++ b/src/audio/miniaudio/ma_context.cpp @@ -14,7 +14,7 @@ std::optional MaContext::Create(ma_context_config &&config, ConstSlic return std::nullopt; } - return std::make_optional(std::move(context)); + return std::move(context); } std::optional MaContext::GetDevices() noexcept { @@ -33,7 +33,7 @@ std::optional MaContext::GetDevices() noexcept { const auto playback_info = PlaybackDeviceInfo(playback_device_infos, playback_device_count); const auto capture_info = CaptureDeviceInfo(capture_device_infos, capture_device_count); - return std::make_optional(std::move(playback_info), std::move(capture_info)); + return DeviceInfo(playback_info, capture_info); } ma_context& MaContext::GetInternal() noexcept { diff --git a/src/audio/miniaudio/ma_device.cpp b/src/audio/miniaudio/ma_device.cpp index ea6c9e28..eb2d853d 100644 --- a/src/audio/miniaudio/ma_device.cpp +++ b/src/audio/miniaudio/ma_device.cpp @@ -14,7 +14,7 @@ std::optional MaDevice::Create(MaContext &context, ma_device_config &c return std::nullopt; } - return std::make_optional(std::move(device)); + return std::move(device); } bool MaDevice::Start() noexcept { diff --git a/src/audio/utils.hpp b/src/audio/utils.hpp index bc55bd22..3cbaa1ad 100644 --- a/src/audio/utils.hpp +++ b/src/audio/utils.hpp @@ -26,7 +26,7 @@ class AudioUtils { static void MixStereoToMono(OutputBuffer buffer) noexcept { for (auto iter = buffer.begin(); iter < buffer.end() - 2; iter += 2) { - const auto mixed = std::reduce(iter, iter + 2, 0) / 2.0f; + const auto mixed = std::reduce(iter, iter + 2, 0.0f) / 2.0f; std::fill(iter, iter + 2, mixed); } } diff --git a/src/audio/voice/capture/effects/gate.cpp b/src/audio/voice/capture/effects/gate.cpp index 87618b02..79d7dd37 100644 --- a/src/audio/voice/capture/effects/gate.cpp +++ b/src/audio/voice/capture/effects/gate.cpp @@ -3,7 +3,7 @@ namespace AbaddonClient::Audio::Voice::Capture::Effects { bool Gate::PassesVAD(InputBuffer buffer, float current_peak) const noexcept { - return current_peak > m_vad_threshold; + return current_peak > VADThreshold; } } diff --git a/src/audio/voice/capture/effects/gate.hpp b/src/audio/voice/capture/effects/gate.hpp index 510d7945..7c78c07c 100644 --- a/src/audio/voice/capture/effects/gate.hpp +++ b/src/audio/voice/capture/effects/gate.hpp @@ -8,7 +8,7 @@ class Gate { public: bool PassesVAD(InputBuffer buffer, float current_peak) const noexcept; - std::atomic m_vad_threshold; + std::atomic VADThreshold; }; } diff --git a/src/audio/voice/capture/effects/noise.cpp b/src/audio/voice/capture/effects/noise.cpp index 4d680f48..781fbb45 100644 --- a/src/audio/voice/capture/effects/noise.cpp +++ b/src/audio/voice/capture/effects/noise.cpp @@ -7,15 +7,15 @@ bool Noise::PassesVAD(InputBuffer buffer) noexcept { const auto prob = m_channels.Lock()[0].DenoiseChannel(buffer, 0); m_peak_meter.SetPeak(prob); - denoised_first_channel = true; + m_denoised_first_channel = true; - return prob > m_vad_threshold; + return prob > VADThreshold; } void Noise::Denoise(OutputBuffer buffer) noexcept { auto start = 0; - if (denoised_first_channel) { - denoised_first_channel = false; + if (m_denoised_first_channel) { + m_denoised_first_channel = false; start = 1; } diff --git a/src/audio/voice/capture/effects/noise.hpp b/src/audio/voice/capture/effects/noise.hpp index d67ce595..3d2546f8 100644 --- a/src/audio/voice/capture/effects/noise.hpp +++ b/src/audio/voice/capture/effects/noise.hpp @@ -5,7 +5,7 @@ #include "audio/voice/capture/constants.hpp" #include "audio/voice/peak_meter/peak_meter.hpp" -#include "rnnoise.h" +#include namespace AbaddonClient::Audio::Voice::Capture::Effects { @@ -32,14 +32,14 @@ class Noise { PeakMeter& GetPeakMeter() noexcept; const PeakMeter& GetPeakMeter() const noexcept; - std::atomic m_vad_threshold = 0.5; + std::atomic VADThreshold = 0.5; private: using NoiseArray = std::array; Mutex m_channels; PeakMeter m_peak_meter; - bool denoised_first_channel; + bool m_denoised_first_channel; }; }; diff --git a/src/audio/voice/capture/voice_capture.cpp b/src/audio/voice/capture/voice_capture.cpp index ea73ade3..375854de 100644 --- a/src/audio/voice/capture/voice_capture.cpp +++ b/src/audio/voice/capture/voice_capture.cpp @@ -9,7 +9,7 @@ using SignalHint = OpusEncoder::SignalHint; using EncodingApplication = OpusEncoder::EncodingApplication; void capture_callback(ma_device* pDevice, void* pOutput, const void* pInput, ma_uint32 frameCount) { - auto capture = reinterpret_cast(pDevice->pUserData); + auto capture = static_cast(pDevice->pUserData); if (capture == nullptr) { return; } @@ -39,10 +39,10 @@ void VoiceCapture::SetActive(bool active) noexcept { m_active = active; } -void VoiceCapture::SetCaptureDevice(ma_device_id &&device_id) noexcept { +void VoiceCapture::SetCaptureDevice(const ma_device_id &device_id) noexcept { spdlog::get("voice")->info("Setting capture device"); - const auto success = m_device.ChangeDevice(std::move(device_id)); + const auto success = m_device.ChangeDevice(device_id); if (!success) { spdlog::get("voice")->error("Failed to set capture device"); } @@ -65,7 +65,7 @@ ma_device_config VoiceCapture::GetDeviceConfig() noexcept { } void VoiceCapture::StartEncoder() noexcept { - auto settings = EncoderSettings {}; + EncoderSettings settings; settings.sample_rate = CAPTURE_SAMPLE_RATE; settings.channels = CAPTURE_CHANNELS; settings.bitrate = 64000; @@ -90,16 +90,16 @@ void VoiceCapture::OnAudioCapture(InputBuffer input) noexcept { ApplyEffects(buffer); if (ApplyNoise(buffer)) { - EncodeAndSend(std::move(buffer)); + EncodeAndSend(buffer); } } void VoiceCapture::ApplyEffects(CaptureBuffer &buffer) noexcept { - if (m_mix_mono) { + if (MixMono) { AudioUtils::MixStereoToMono(buffer); } - AudioUtils::ApplyGain(buffer, m_gain); + AudioUtils::ApplyGain(buffer, Gain); m_peak_meter.UpdatePeak(buffer); } @@ -108,14 +108,14 @@ bool VoiceCapture::ApplyNoise(CaptureBuffer &buffer) noexcept { return false; } - if (m_suppress_noise) { + if (SuppressNoise) { m_effects.Denoise(buffer); } return true; } -void VoiceCapture::EncodeAndSend(CaptureBuffer &&buffer) noexcept { +void VoiceCapture::EncodeAndSend(const CaptureBuffer &buffer) noexcept { std::vector opus; opus.resize(1275); @@ -123,7 +123,7 @@ void VoiceCapture::EncodeAndSend(CaptureBuffer &&buffer) noexcept { opus.resize(bytes); if (bytes > 0) { - m_signal.emit(std::move(opus)); + m_signal.emit(opus); } m_rtp_timestamp += CAPTURE_FRAME_SIZE; diff --git a/src/audio/voice/capture/voice_capture.hpp b/src/audio/voice/capture/voice_capture.hpp index a8c4c9fe..5e4e8553 100644 --- a/src/audio/voice/capture/voice_capture.hpp +++ b/src/audio/voice/capture/voice_capture.hpp @@ -13,7 +13,7 @@ namespace AbaddonClient::Audio::Voice { class VoiceCapture { public: - using CaptureSignal = sigc::signal)>; + using CaptureSignal = sigc::signal&)>; VoiceCapture(Context &context) noexcept; @@ -21,7 +21,7 @@ class VoiceCapture { void Stop() noexcept; void SetActive(bool active) noexcept; - void SetCaptureDevice(ma_device_id &&device_id) noexcept; + void SetCaptureDevice(const ma_device_id &device_id) noexcept; Capture::VoiceEffects& GetEffects() noexcept; const Capture::VoiceEffects& GetEffects() const noexcept; @@ -35,9 +35,9 @@ class VoiceCapture { CaptureSignal GetCaptureSignal() const noexcept; uint32_t GetRTPTimestamp() const noexcept; - std::atomic m_gain = 1.0f; - std::atomic m_mix_mono = false; - std::atomic m_suppress_noise = false; + std::atomic Gain = 1.0f; + std::atomic MixMono = false; + std::atomic SuppressNoise = false; private: ma_device_config GetDeviceConfig() noexcept; void StartEncoder() noexcept; @@ -46,7 +46,7 @@ class VoiceCapture { void ApplyEffects(CaptureBuffer &buffer) noexcept; bool ApplyNoise(CaptureBuffer &buffer) noexcept; - void EncodeAndSend(CaptureBuffer &&buffer) noexcept; + void EncodeAndSend(const CaptureBuffer &buffer) noexcept; Capture::VoiceEffects m_effects; PeakMeter m_peak_meter; diff --git a/src/audio/voice/opus/opus_decoder.cpp b/src/audio/voice/opus/opus_decoder.cpp index dae410d7..c19dbfde 100644 --- a/src/audio/voice/opus/opus_decoder.cpp +++ b/src/audio/voice/opus/opus_decoder.cpp @@ -16,7 +16,7 @@ OpusDecoder::Create(const DecoderSettings settings) noexcept { } auto decoder_ptr = DecoderPtr(decoder, opus_decoder_destroy); - return std::make_optional(std::move(decoder_ptr), std::move(settings)); + return OpusDecoder(std::move(decoder_ptr), std::move(settings)); } int OpusDecoder::Decode(OpusInput opus, OutputBuffer output, const int frame_size) noexcept { diff --git a/src/audio/voice/opus/opus_encoder.cpp b/src/audio/voice/opus/opus_encoder.cpp index 2b26dd3b..c6152f01 100644 --- a/src/audio/voice/opus/opus_encoder.cpp +++ b/src/audio/voice/opus/opus_encoder.cpp @@ -29,7 +29,7 @@ OpusEncoder::Create(EncoderSettings settings) noexcept { } auto encoder_ptr = EncoderPtr(encoder, opus_encoder_destroy); - return std::make_optional(std::move(encoder_ptr), std::move(settings)); + return OpusEncoder(std::move(encoder_ptr), settings); } int OpusEncoder::Encode(InputBuffer &&input, OpusOutput &&output, int frame_size) noexcept { @@ -63,10 +63,15 @@ void OpusEncoder::SetSignalHint(SignalHint hint) noexcept { } void OpusEncoder::SetEncodingApplication(EncodingApplication application) noexcept { - // TODO: It should be fine to omit the error check here since we're only changing the application? int error; - auto ptr = opus_encoder_create(m_sample_rate, m_channels, static_cast(application), &error); - m_encoder.reset(ptr); + const auto encoder = opus_encoder_create(m_sample_rate, m_channels, static_cast(application), &error); + + if (error != OPUS_OK) { + spdlog::get("voice")->error("Cannot change encoding application: {}", opus_strerror(error)); + return; + } + + m_encoder.reset(encoder); SetBitrate(m_bitrate); SetSignalHint(m_signal_hint); diff --git a/src/audio/voice/playback/client.cpp b/src/audio/voice/playback/client.cpp index 2e71576b..e5583f14 100644 --- a/src/audio/voice/playback/client.cpp +++ b/src/audio/voice/playback/client.cpp @@ -7,7 +7,7 @@ Client::Client(Opus::OpusDecoder &&decoder, VoiceBuffer &&buffer, DecodePool &de m_buffer( std::make_shared( std::move(buffer) )), m_decode_pool(decode_pool) {} -void Client::DecodeFromRTP(const std::vector &&rtp) noexcept { +void Client::DecodeFromRTP(std::vector &&rtp) noexcept { if (m_muted) { return; } @@ -27,7 +27,7 @@ void Client::WriteAudio(OutputBuffer buffer) noexcept { } m_buffer->Read(buffer); - AudioUtils::ApplyGain(buffer, m_volume); + AudioUtils::ApplyGain(buffer, Volume); m_peak_meter.UpdatePeak(buffer); } diff --git a/src/audio/voice/playback/client.hpp b/src/audio/voice/playback/client.hpp index 71cf7e1b..7543de93 100644 --- a/src/audio/voice/playback/client.hpp +++ b/src/audio/voice/playback/client.hpp @@ -11,7 +11,7 @@ class Client { public: Client(Opus::OpusDecoder &&decoder, VoiceBuffer &&buffer, DecodePool &decode_pool) noexcept; - void DecodeFromRTP(const std::vector &&rtp) noexcept; + void DecodeFromRTP(std::vector &&rtp) noexcept; void WriteAudio(OutputBuffer output) noexcept; void ClearBuffer() noexcept; @@ -22,7 +22,7 @@ class Client { PeakMeter& GetPeakMeter() noexcept; const PeakMeter& GetPeakMeter() const noexcept; - std::atomic m_volume = 1.0; + std::atomic Volume = 1.0; private: SharedDecoder m_decoder; diff --git a/src/audio/voice/playback/client_store.cpp b/src/audio/voice/playback/client_store.cpp index 81ea91ce..a4053f5b 100644 --- a/src/audio/voice/playback/client_store.cpp +++ b/src/audio/voice/playback/client_store.cpp @@ -11,7 +11,7 @@ void ClientStore::AddClient(ClientID id) noexcept { return; }; - auto settings = Opus::OpusDecoder::DecoderSettings {}; + Opus::OpusDecoder::DecoderSettings settings; settings.sample_rate = RTP_SAMPLE_RATE; settings.channels = RTP_CHANNELS; @@ -49,7 +49,7 @@ void ClientStore::Clear() noexcept { m_clients.Lock()->clear(); } -void ClientStore::DecodeFromRTP(ClientID id, const std::vector &&data) noexcept { +void ClientStore::DecodeFromRTP(ClientID id, std::vector &&data) noexcept { auto clients = m_clients.Lock(); auto client = clients->find(id); @@ -87,12 +87,12 @@ void ClientStore::DecayPeakMeters() noexcept { } } -void ClientStore::SetClientVolume(const ClientID id, const float volume) noexcept { +void ClientStore::SetClientVolume(ClientID id, float volume) noexcept { auto clients = m_clients.Lock(); auto client = clients->find(id); if (client != clients->end()) { - client->second.m_volume = volume; + client->second.Volume = volume; } } @@ -104,7 +104,7 @@ void ClientStore::ClearAllBuffers() noexcept { } } -void ClientStore::SetClientMute(const ClientID id, const bool muted) noexcept { +void ClientStore::SetClientMute(ClientID id, bool muted) noexcept { auto clients = m_clients.Lock(); auto client = clients->find(id); @@ -113,18 +113,18 @@ void ClientStore::SetClientMute(const ClientID id, const bool muted) noexcept { } } -float ClientStore::GetClientVolume(const ClientID id) const noexcept { +float ClientStore::GetClientVolume(ClientID id) const noexcept { const auto clients = m_clients.Lock(); const auto client = clients->find(id); if (client != clients->end()) { - return client->second.m_volume; + return client->second.Volume; } return 1.0; } -bool ClientStore::GetClientMute(const ClientID id) const noexcept { +bool ClientStore::GetClientMute(ClientID id) const noexcept { const auto clients = m_clients.Lock(); const auto client = clients->find(id); @@ -135,7 +135,7 @@ bool ClientStore::GetClientMute(const ClientID id) const noexcept { return false; } -float ClientStore::GetClientPeakVolume(const ClientID id) const noexcept { +float ClientStore::GetClientPeakVolume(ClientID id) const noexcept { const auto clients = m_clients.Lock(); const auto client = clients->find(id); diff --git a/src/audio/voice/playback/client_store.hpp b/src/audio/voice/playback/client_store.hpp index 0f441021..ebaceead 100644 --- a/src/audio/voice/playback/client_store.hpp +++ b/src/audio/voice/playback/client_store.hpp @@ -17,26 +17,26 @@ class ClientStore { public: using ClientID = uint32_t; - void AddClient(const ClientID id) noexcept; - void RemoveClient(const ClientID id) noexcept; + void AddClient(ClientID id) noexcept; + void RemoveClient(ClientID id) noexcept; void Clear() noexcept; void DecayPeakMeters() noexcept; void ClearAllBuffers() noexcept; - void SetClientVolume(const ClientID id, const float volume) noexcept; - void SetClientMute(const ClientID id, const bool muted) noexcept; + void SetClientVolume(ClientID id, float volume) noexcept; + void SetClientMute(ClientID id, bool muted) noexcept; - float GetClientVolume(const ClientID id) const noexcept; - bool GetClientMute(const ClientID id) const noexcept; + float GetClientVolume(ClientID id) const noexcept; + bool GetClientMute(ClientID id) const noexcept; - float GetClientPeakVolume(const ClientID id) const noexcept; + float GetClientPeakVolume(ClientID id) const noexcept; private: using ClientMap = std::unordered_map; // Keep these two private and expose through VoicePlayback - void DecodeFromRTP(const ClientID id, const std::vector &&data) noexcept; + void DecodeFromRTP(ClientID id, std::vector &&data) noexcept; void WriteMixed(OutputBuffer buffer) noexcept; DecodePool m_decode_pool; diff --git a/src/audio/voice/playback/decode_pool.cpp b/src/audio/voice/playback/decode_pool.cpp index 311f2aca..c306b258 100644 --- a/src/audio/voice/playback/decode_pool.cpp +++ b/src/audio/voice/playback/decode_pool.cpp @@ -14,15 +14,7 @@ Opus::OpusInput StripRTPExtensionHeader(ConstSlice rtp) { } DecodePool::DecodePool() noexcept : - m_pool(Pool(&DecodePool::DecodeThread, 20)) -{ - auto concurrency = std::thread::hardware_concurrency() / 2; - if (concurrency == 0) { - concurrency = 2; - } - - m_pool.m_max_threads = concurrency; -} + m_pool(Pool(&DecodePool::DecodeThread, 20)) {} void DecodePool::DecodeFromRTP(DecodeData &&decode_data) noexcept { m_pool.SendToPool(std::move(decode_data)); @@ -57,7 +49,7 @@ void DecodePool::DecodeThread(Channel &channel) noexcept { } void DecodePool::OnDecodeMessage(DecodeData &&message) noexcept { - auto&& [rtp, decoder, buffer] = message; + auto& [rtp, decoder, buffer] = message; static std::array pcm; const auto opus = StripRTPExtensionHeader(rtp); diff --git a/src/audio/voice/playback/decode_pool.hpp b/src/audio/voice/playback/decode_pool.hpp index cf5fc0d8..96a1cc4c 100644 --- a/src/audio/voice/playback/decode_pool.hpp +++ b/src/audio/voice/playback/decode_pool.hpp @@ -15,7 +15,7 @@ class DecodePool { DecodePool() noexcept; struct DecodeData { - const std::vector rtp; + std::vector rtp; SharedDecoder decoder; SharedBuffer buffer; diff --git a/src/audio/voice/playback/voice_buffer.cpp b/src/audio/voice/playback/voice_buffer.cpp index 8ecc8c3a..c0857aca 100644 --- a/src/audio/voice/playback/voice_buffer.cpp +++ b/src/audio/voice/playback/voice_buffer.cpp @@ -16,7 +16,7 @@ std::optional VoiceBuffer::Create(uint32_t channels, uint32_t buffe return std::nullopt; } - return std::make_optional(std::move(ringbuffer_ptr), channels, buffer_frames); + return VoiceBuffer(std::move(ringbuffer_ptr), channels, buffer_frames); } void VoiceBuffer::Read(OutputBuffer output) noexcept { diff --git a/src/audio/voice/playback/voice_playback.cpp b/src/audio/voice/playback/voice_playback.cpp index 0b16ae91..c327087c 100644 --- a/src/audio/voice/playback/voice_playback.cpp +++ b/src/audio/voice/playback/voice_playback.cpp @@ -4,7 +4,7 @@ namespace AbaddonClient::Audio::Voice { void playback_callback(ma_device* pDevice, void* pOutput, const void* pInput, ma_uint32 frameCount) { - auto playback = reinterpret_cast(pDevice->pUserData); + auto playback = static_cast(pDevice->pUserData); if (playback == nullptr) { return; } @@ -16,7 +16,7 @@ void playback_callback(ma_device* pDevice, void* pOutput, const void* pInput, ma VoicePlayback::VoicePlayback(Context &context) noexcept : m_device(context, GetDeviceConfig(), context.GetActivePlaybackID()) {} -void VoicePlayback::OnRTPData(ClientID id, const std::vector &&data) noexcept { +void VoicePlayback::OnRTPData(ClientID id, std::vector &&data) noexcept { if (m_active) { m_clients.DecodeFromRTP(id, std::move(data)); } @@ -46,10 +46,10 @@ void VoicePlayback::SetActive(bool active) noexcept { m_active = active; } -void VoicePlayback::SetPlaybackDevice(ma_device_id &&device_id) noexcept { +void VoicePlayback::SetPlaybackDevice(const ma_device_id &device_id) noexcept { spdlog::get("voice")->info("Setting playback device"); - const auto success = m_device.ChangeDevice(std::move(device_id)); + const auto success = m_device.ChangeDevice(device_id); if (!success) { spdlog::get("voice")->error("Failed to set playback device"); } diff --git a/src/audio/voice/playback/voice_playback.hpp b/src/audio/voice/playback/voice_playback.hpp index cd2a69e3..428034ea 100644 --- a/src/audio/voice/playback/voice_playback.hpp +++ b/src/audio/voice/playback/voice_playback.hpp @@ -14,13 +14,13 @@ class VoicePlayback { VoicePlayback(Context &context) noexcept; - void OnRTPData(ClientID id, const std::vector &&data) noexcept; + void OnRTPData(ClientID id, std::vector &&data) noexcept; void Start() noexcept; void Stop() noexcept; void SetActive(bool active) noexcept; - void SetPlaybackDevice(ma_device_id &&device_id) noexcept; + void SetPlaybackDevice(const ma_device_id &device_id) noexcept; Playback::ClientStore& GetClientStore() noexcept; const Playback::ClientStore& GetClientStore() const noexcept; diff --git a/src/misc/mutex.hpp b/src/misc/mutex.hpp index d64e31b0..f647e67f 100644 --- a/src/misc/mutex.hpp +++ b/src/misc/mutex.hpp @@ -64,7 +64,6 @@ template class Mutex { public: - Mutex() {} Mutex(T&& object) : m_object(std::forward(object)) {} template diff --git a/src/misc/threadpool.hpp b/src/misc/threadpool.hpp index 42bc8bfd..3649558e 100644 --- a/src/misc/threadpool.hpp +++ b/src/misc/threadpool.hpp @@ -1,5 +1,6 @@ #pragma once +#include "thread" #include "variant" #include "channel.hpp" @@ -11,18 +12,24 @@ class ThreadPool { public: using ThreadMessage = std::variant; - ThreadPool() noexcept; - ThreadPool(Callable callable, size_t channel_capacity) noexcept : m_callable(std::move(callable)), - m_channel(Channel(channel_capacity)) {} + m_channel(Channel(channel_capacity)) + { + auto concurrency = std::thread::hardware_concurrency() / 2; + if (concurrency == 0) { + concurrency = 2; + } + + MaxThreads = concurrency; + } ~ThreadPool() noexcept { Clear(); } void AddThread() noexcept { - if (GetThreadCount() == m_max_threads) { + if (GetThreadCount() == MaxThreads) { return; } @@ -59,7 +66,7 @@ class ThreadPool { m_channel.Send(std::move(data)); } - size_t m_max_threads; + size_t MaxThreads; private: std::vector m_threads; From c59c496c0651d45273e5d340ae2ae9eed5515b6f Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Wed, 22 May 2024 16:05:04 +0300 Subject: [PATCH 12/39] Fix switching devices --- src/audio/audio_device.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/audio/audio_device.cpp b/src/audio/audio_device.cpp index b7a17b4d..67d838fb 100644 --- a/src/audio/audio_device.cpp +++ b/src/audio/audio_device.cpp @@ -80,6 +80,7 @@ void AudioDevice::SyncDeviceID() noexcept { bool AudioDevice::RefreshDevice() noexcept { m_device.reset(); if (m_started) { + m_started = false; return Start(); } From 6221b6020becf4f4bbf00140fbf203f13048626c Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Wed, 22 May 2024 16:08:30 +0300 Subject: [PATCH 13/39] Use functors for deleters --- src/audio/miniaudio/ma_context.cpp | 4 ++-- src/audio/miniaudio/ma_context.hpp | 18 ++++++++++++------ src/audio/miniaudio/ma_device.cpp | 4 ++-- src/audio/miniaudio/ma_device.hpp | 18 +++++++++++++----- src/audio/voice/capture/effects/noise.cpp | 2 +- src/audio/voice/capture/effects/noise.hpp | 8 +++++++- src/audio/voice/opus/opus_decoder.cpp | 4 ++-- src/audio/voice/opus/opus_decoder.hpp | 11 ++++++++--- src/audio/voice/opus/opus_encoder.cpp | 2 +- src/audio/voice/opus/opus_encoder.hpp | 12 +++++++++--- src/audio/voice/playback/voice_buffer.cpp | 2 +- src/audio/voice/playback/voice_buffer.hpp | 12 +++++++++--- 12 files changed, 67 insertions(+), 30 deletions(-) diff --git a/src/audio/miniaudio/ma_context.cpp b/src/audio/miniaudio/ma_context.cpp index 6b01b133..ce234c75 100644 --- a/src/audio/miniaudio/ma_context.cpp +++ b/src/audio/miniaudio/ma_context.cpp @@ -6,7 +6,7 @@ MaContext::MaContext(ContextPtr &&context) noexcept : m_context(std::move(context)) {} std::optional MaContext::Create(ma_context_config &&config, ConstSlice backends) noexcept { - ContextPtr context = ContextPtr(new ma_context, &ma_context_uninit); + ContextPtr context = ContextPtr(new ma_context); const auto result = ma_context_init(backends.data(), backends.size(), &config, context.get()); if (result != MA_SUCCESS) { @@ -14,7 +14,7 @@ std::optional MaContext::Create(ma_context_config &&config, ConstSlic return std::nullopt; } - return std::move(context); + return MaContext(std::move(context)); } std::optional MaContext::GetDevices() noexcept { diff --git a/src/audio/miniaudio/ma_context.hpp b/src/audio/miniaudio/ma_context.hpp index 85bef058..79a72bd1 100644 --- a/src/audio/miniaudio/ma_context.hpp +++ b/src/audio/miniaudio/ma_context.hpp @@ -12,12 +12,6 @@ class MaContext { using CaptureDeviceInfo = ConstSlice; using DeviceInfo = std::pair; - // Put ma_context behind pointer to allow moving. - // miniaudio expects ma_context reference to be valid at all times - // Moving it to other location would cause memory corruption - using ContextPtr = std::unique_ptr; - - MaContext(ContextPtr &&context) noexcept; static std::optional Create(ma_context_config &&config, ConstSlice backends) noexcept; std::optional GetDevices() noexcept; @@ -25,6 +19,18 @@ class MaContext { ma_context& GetInternal() noexcept; private: + struct ContextDeleter { + void operator()(ma_context* ptr) noexcept { + ma_context_uninit(ptr); + } + }; + + // Put ma_context behind pointer to allow moving. + // miniaudio expects ma_context reference to be valid at all times + // Moving it to other location would cause memory corruption + using ContextPtr = std::unique_ptr; + MaContext(ContextPtr &&context) noexcept; + ContextPtr m_context; }; diff --git a/src/audio/miniaudio/ma_device.cpp b/src/audio/miniaudio/ma_device.cpp index eb2d853d..3678f82a 100644 --- a/src/audio/miniaudio/ma_device.cpp +++ b/src/audio/miniaudio/ma_device.cpp @@ -6,7 +6,7 @@ MaDevice::MaDevice(DevicePtr &&device) noexcept : m_device(std::move(device)) {} std::optional MaDevice::Create(MaContext &context, ma_device_config &config) noexcept { - DevicePtr device = DevicePtr(new ma_device, &ma_device_uninit); + DevicePtr device = DevicePtr(new ma_device); const auto result = ma_device_init(&context.GetInternal(), &config, device.get()); if (result != MA_SUCCESS) { @@ -14,7 +14,7 @@ std::optional MaDevice::Create(MaContext &context, ma_device_config &c return std::nullopt; } - return std::move(device); + return MaDevice(std::move(device)); } bool MaDevice::Start() noexcept { diff --git a/src/audio/miniaudio/ma_device.hpp b/src/audio/miniaudio/ma_device.hpp index 75bac295..4255c87a 100644 --- a/src/audio/miniaudio/ma_device.hpp +++ b/src/audio/miniaudio/ma_device.hpp @@ -8,11 +8,6 @@ namespace AbaddonClient::Audio::Miniaudio { class MaDevice { public: - // Put ma_device behind pointer to allow moving - // miniaudio expects ma_device reference to be valid at all times. - // Moving it to other location would cause memory corruption - using DevicePtr = std::unique_ptr; - MaDevice(DevicePtr &&device) noexcept; static std::optional Create(MaContext &context, ma_device_config &config) noexcept; @@ -22,6 +17,19 @@ class MaDevice { ma_device& GetInternal() noexcept; private: + struct DeviceDeleter { + void operator()(ma_device* ptr) noexcept { + ma_device_uninit(ptr); + } + }; + + // Put ma_device behind pointer to allow moving + // miniaudio expects ma_device reference to be valid at all times. + // Moving it to other location would cause memory corruption + using DevicePtr = std::unique_ptr; + MaDevice(DevicePtr &&device) noexcept; + + DevicePtr m_device; }; diff --git a/src/audio/voice/capture/effects/noise.cpp b/src/audio/voice/capture/effects/noise.cpp index 781fbb45..910306c2 100644 --- a/src/audio/voice/capture/effects/noise.cpp +++ b/src/audio/voice/capture/effects/noise.cpp @@ -37,7 +37,7 @@ const PeakMeter& Noise::GetPeakMeter() const noexcept { } NoiseBuffer::NoiseBuffer() noexcept : - m_state(RNNoisePtr(rnnoise_create(NULL), rnnoise_destroy)) {} + m_state(RNNoisePtr(rnnoise_create(NULL))) {} float NoiseBuffer::DenoiseChannel(InputBuffer buffer, size_t channel) noexcept { ChannelBuffer input_channel; diff --git a/src/audio/voice/capture/effects/noise.hpp b/src/audio/voice/capture/effects/noise.hpp index 3d2546f8..f106c0a7 100644 --- a/src/audio/voice/capture/effects/noise.hpp +++ b/src/audio/voice/capture/effects/noise.hpp @@ -17,7 +17,13 @@ class NoiseBuffer { void WriteChannel(OutputBuffer buffer, size_t channel) noexcept; private: - using RNNoisePtr = std::unique_ptr; + struct RNNoiseDeleter { + void operator()(DenoiseState* ptr) noexcept { + rnnoise_destroy(ptr); + } + }; + + using RNNoisePtr = std::unique_ptr; using ChannelBuffer = std::array; RNNoisePtr m_state; diff --git a/src/audio/voice/opus/opus_decoder.cpp b/src/audio/voice/opus/opus_decoder.cpp index c19dbfde..3d7bf5d4 100644 --- a/src/audio/voice/opus/opus_decoder.cpp +++ b/src/audio/voice/opus/opus_decoder.cpp @@ -15,8 +15,8 @@ OpusDecoder::Create(const DecoderSettings settings) noexcept { return std::nullopt; } - auto decoder_ptr = DecoderPtr(decoder, opus_decoder_destroy); - return OpusDecoder(std::move(decoder_ptr), std::move(settings)); + auto decoder_ptr = DecoderPtr(decoder); + return OpusDecoder(std::move(decoder_ptr), settings); } int OpusDecoder::Decode(OpusInput opus, OutputBuffer output, const int frame_size) noexcept { diff --git a/src/audio/voice/opus/opus_decoder.hpp b/src/audio/voice/opus/opus_decoder.hpp index f3a375c4..53f9d024 100644 --- a/src/audio/voice/opus/opus_decoder.hpp +++ b/src/audio/voice/opus/opus_decoder.hpp @@ -10,18 +10,23 @@ using OpusInput = ConstSlice; class OpusDecoder { public: - using DecoderPtr = std::unique_ptr<::OpusDecoder, decltype(&opus_decoder_destroy)>; - struct DecoderSettings { int32_t sample_rate = 48000; int channels = 2; }; - OpusDecoder(DecoderPtr encoder, DecoderSettings settings) noexcept; static std::optional Create(DecoderSettings settings) noexcept; int Decode(OpusInput opus, OutputBuffer pcm, int frame_size) noexcept; private: + struct DecoderDeleter { + void operator()(::OpusDecoder* ptr) noexcept { + opus_decoder_destroy(ptr); + } + }; + + using DecoderPtr = std::unique_ptr<::OpusDecoder, DecoderDeleter>; + OpusDecoder(DecoderPtr encoder, DecoderSettings settings) noexcept; DecoderPtr m_encoder; }; diff --git a/src/audio/voice/opus/opus_encoder.cpp b/src/audio/voice/opus/opus_encoder.cpp index c6152f01..7d0a4775 100644 --- a/src/audio/voice/opus/opus_encoder.cpp +++ b/src/audio/voice/opus/opus_encoder.cpp @@ -28,7 +28,7 @@ OpusEncoder::Create(EncoderSettings settings) noexcept { return std::nullopt; } - auto encoder_ptr = EncoderPtr(encoder, opus_encoder_destroy); + auto encoder_ptr = EncoderPtr(encoder); return OpusEncoder(std::move(encoder_ptr), settings); } diff --git a/src/audio/voice/opus/opus_encoder.hpp b/src/audio/voice/opus/opus_encoder.hpp index 75a06467..680361f9 100644 --- a/src/audio/voice/opus/opus_encoder.hpp +++ b/src/audio/voice/opus/opus_encoder.hpp @@ -10,8 +10,6 @@ using OpusOutput = Slice; class OpusEncoder { public: - using EncoderPtr = std::unique_ptr<::OpusEncoder, decltype(&opus_encoder_destroy)>; - enum class EncodingApplication { Audio = OPUS_APPLICATION_AUDIO, LowDelay = OPUS_APPLICATION_RESTRICTED_LOWDELAY, @@ -32,7 +30,6 @@ class OpusEncoder { EncodingApplication application = EncodingApplication::Audio; }; - OpusEncoder(EncoderPtr encoder, EncoderSettings settings) noexcept; static std::optional Create(EncoderSettings settings) noexcept; int Encode(InputBuffer &&audio, OpusOutput &&opus, int frame_size) noexcept; @@ -46,6 +43,15 @@ class OpusEncoder { SignalHint GetSignalHint() const noexcept; EncodingApplication GetEncodingApplication() const noexcept; private: + struct EncoderDeleter { + void operator()(::OpusEncoder* ptr) noexcept { + opus_encoder_destroy(ptr); + } + }; + + using EncoderPtr = std::unique_ptr<::OpusEncoder, EncoderDeleter>; + OpusEncoder(EncoderPtr encoder, EncoderSettings settings) noexcept; + template bool OpusCTL(std::string_view request, Args&& ...args) noexcept; diff --git a/src/audio/voice/playback/voice_buffer.cpp b/src/audio/voice/playback/voice_buffer.cpp index c0857aca..769208c1 100644 --- a/src/audio/voice/playback/voice_buffer.cpp +++ b/src/audio/voice/playback/voice_buffer.cpp @@ -8,7 +8,7 @@ VoiceBuffer::VoiceBuffer(RingBufferPtr &&ringbuffer, uint32_t channels, uint32_t m_buffer_frames(buffer_frames) {} std::optional VoiceBuffer::Create(uint32_t channels, uint32_t buffer_size_in_frames, uint32_t buffer_frames) noexcept { - auto ringbuffer_ptr = RingBufferPtr(new ma_pcm_rb, &ma_pcm_rb_uninit); + auto ringbuffer_ptr = RingBufferPtr(new ma_pcm_rb); const auto result = ma_pcm_rb_init(ma_format_f32, channels, buffer_size_in_frames, nullptr, nullptr, ringbuffer_ptr.get()); if (result != MA_SUCCESS) { diff --git a/src/audio/voice/playback/voice_buffer.hpp b/src/audio/voice/playback/voice_buffer.hpp index 1f83c765..c0f42f01 100644 --- a/src/audio/voice/playback/voice_buffer.hpp +++ b/src/audio/voice/playback/voice_buffer.hpp @@ -6,9 +6,6 @@ namespace AbaddonClient::Audio::Voice::Playback { class VoiceBuffer { public: - using RingBufferPtr = std::unique_ptr; - - VoiceBuffer(RingBufferPtr &&ringbuffer, uint32_t channels, uint32_t buffer_frames) noexcept; static std::optional Create(uint32_t channels, uint32_t buffer_size_in_frames, uint32_t buffer_frames) noexcept; void Read(OutputBuffer output) noexcept; @@ -16,6 +13,15 @@ class VoiceBuffer { void Clear() noexcept; private: + struct RingBufferDeleter { + void operator()(ma_pcm_rb* ptr) noexcept { + ma_pcm_rb_uninit(ptr); + } + }; + + using RingBufferPtr = std::unique_ptr; + VoiceBuffer(RingBufferPtr &&ringbuffer, uint32_t channels, uint32_t buffer_frames) noexcept; + RingBufferPtr m_ringbuffer; uint32_t m_channels; From 896714b3bf7f1b6ff2ecc6a72a8847fc4245e452 Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Wed, 22 May 2024 19:23:39 +0300 Subject: [PATCH 14/39] Split VoiceBuffer miniaudio ringbuffer --- src/audio/miniaudio/ma_pcm_rb.cpp | 88 +++++++++++++++++++++++ src/audio/miniaudio/ma_pcm_rb.hpp | 32 +++++++++ src/audio/voice/playback/voice_buffer.cpp | 64 +++-------------- src/audio/voice/playback/voice_buffer.hpp | 19 ++--- 4 files changed, 132 insertions(+), 71 deletions(-) create mode 100644 src/audio/miniaudio/ma_pcm_rb.cpp create mode 100644 src/audio/miniaudio/ma_pcm_rb.hpp diff --git a/src/audio/miniaudio/ma_pcm_rb.cpp b/src/audio/miniaudio/ma_pcm_rb.cpp new file mode 100644 index 00000000..249af47a --- /dev/null +++ b/src/audio/miniaudio/ma_pcm_rb.cpp @@ -0,0 +1,88 @@ +#include "ma_pcm_rb.hpp" + +namespace AbaddonClient::Audio::Miniaudio { + +MaPCMRingBuffer::MaPCMRingBuffer(RingBufferPtr &&ringbuffer, uint32_t channels) noexcept : + m_ringbuffer(std::move(ringbuffer)), + m_channels(channels) {} + + +std::optional +MaPCMRingBuffer::Create(uint32_t channels, uint32_t buffer_size_in_frames) noexcept { + auto ringbuffer_ptr = RingBufferPtr(new ma_pcm_rb); + + const auto result = ma_pcm_rb_init(ma_format_f32, channels, buffer_size_in_frames, nullptr, nullptr, ringbuffer_ptr.get()); + if (result != MA_SUCCESS) { + spdlog::get("voice")->error("Failed to create MaPCMRingBuffer: {}", ma_result_description(result)); + return std::nullopt; + } + + return MaPCMRingBuffer(std::move(ringbuffer_ptr), channels); +} + + +void MaPCMRingBuffer::Read(OutputBuffer output) noexcept { + const auto total_frames = output.size() / m_channels; + + uint32_t read = 0; + uint32_t tries = 0; + + // Try twice in case of wrap around + while (read < total_frames && tries < 2) { + uint32_t frames = total_frames - read; + float* read_ptr; + + ma_pcm_rb_acquire_read(m_ringbuffer.get(), &frames, reinterpret_cast(&read_ptr)); + + const auto start = read_ptr; + const auto end = start + frames * m_channels + 1; + const auto result = output.begin() + read * m_channels; + + std::copy(start, end, result); + + ma_pcm_rb_commit_read(m_ringbuffer.get(), frames); + + read += frames; + tries++; + } +} + +void MaPCMRingBuffer::Write(InputBuffer input) noexcept { + const auto total_frames = input.size() / m_channels; + + uint32_t written = 0; + uint32_t tries = 0; + + // Try twice in case of wrap around + while (written < total_frames && tries < 2) { + uint32_t frames = total_frames - written; + float* write_ptr; + + ma_pcm_rb_acquire_write(m_ringbuffer.get(), &frames, reinterpret_cast(&write_ptr)); + + const auto start = input.begin() + written * m_channels; + const auto end = start + frames * m_channels + 1; + + std::copy(start, end, write_ptr); + + ma_pcm_rb_commit_write(m_ringbuffer.get(), frames); + + written += frames; + tries++; + } +} + +void MaPCMRingBuffer::Clear() noexcept { + ma_pcm_rb_reset(m_ringbuffer.get()); +} + +uint32_t MaPCMRingBuffer::GetAvailableReadFrames() noexcept { + return ma_pcm_rb_available_read(m_ringbuffer.get()); +} + +uint32_t MaPCMRingBuffer::GetAvailableWriteFrames() noexcept { + return ma_pcm_rb_available_write(m_ringbuffer.get()); +} + + +} diff --git a/src/audio/miniaudio/ma_pcm_rb.hpp b/src/audio/miniaudio/ma_pcm_rb.hpp new file mode 100644 index 00000000..c667cdd5 --- /dev/null +++ b/src/audio/miniaudio/ma_pcm_rb.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include + +namespace AbaddonClient::Audio::Miniaudio { + +class MaPCMRingBuffer { +public: + static std::optional Create(uint32_t channels, uint32_t buffer_size_in_frames) noexcept; + + void Read(OutputBuffer output) noexcept; + void Write(InputBuffer input) noexcept; + void Clear() noexcept; + + uint32_t GetAvailableReadFrames() noexcept; + uint32_t GetAvailableWriteFrames() noexcept; +private: + struct RingBufferDeleter { + void operator()(ma_pcm_rb* ptr) noexcept { + ma_pcm_rb_uninit(ptr); + } + }; + + using RingBufferPtr = std::unique_ptr; + MaPCMRingBuffer(RingBufferPtr &&ringbuffer, uint32_t channel) noexcept; + + RingBufferPtr m_ringbuffer; + + const uint32_t m_channels; +}; + +} diff --git a/src/audio/voice/playback/voice_buffer.cpp b/src/audio/voice/playback/voice_buffer.cpp index 769208c1..4d0accc5 100644 --- a/src/audio/voice/playback/voice_buffer.cpp +++ b/src/audio/voice/playback/voice_buffer.cpp @@ -2,82 +2,34 @@ namespace AbaddonClient::Audio::Voice::Playback { -VoiceBuffer::VoiceBuffer(RingBufferPtr &&ringbuffer, uint32_t channels, uint32_t buffer_frames) noexcept : +VoiceBuffer::VoiceBuffer(Miniaudio::MaPCMRingBuffer &&ringbuffer, uint32_t buffer_frames) noexcept : m_ringbuffer(std::move(ringbuffer)), - m_channels(channels), m_buffer_frames(buffer_frames) {} std::optional VoiceBuffer::Create(uint32_t channels, uint32_t buffer_size_in_frames, uint32_t buffer_frames) noexcept { - auto ringbuffer_ptr = RingBufferPtr(new ma_pcm_rb); + auto ringbuffer = Miniaudio::MaPCMRingBuffer::Create(channels, buffer_size_in_frames); - const auto result = ma_pcm_rb_init(ma_format_f32, channels, buffer_size_in_frames, nullptr, nullptr, ringbuffer_ptr.get()); - if (result != MA_SUCCESS) { - spdlog::get("voice")->error("Failed to create voice buffer: {}", ma_result_description(result)); + if (!ringbuffer) { return std::nullopt; } - return VoiceBuffer(std::move(ringbuffer_ptr), channels, buffer_frames); + return VoiceBuffer(std::move(*ringbuffer), buffer_frames); } void VoiceBuffer::Read(OutputBuffer output) noexcept { - const auto total_frames = output.size() / m_channels; - const auto available_frames = ma_pcm_rb_available_read(m_ringbuffer.get()); - - // Make sure to always leave some distance - if (available_frames < m_buffer_frames) { + if (m_ringbuffer.GetAvailableReadFrames() < m_buffer_frames) { return; } - uint32_t read = 0; - uint32_t tries = 0; - - // Try twice in case of wrap around - while (read < total_frames && tries < 2) { - uint32_t frames = total_frames - read; - float* read_ptr; - - ma_pcm_rb_acquire_read(m_ringbuffer.get(), &frames, reinterpret_cast(&read_ptr)); - - const auto start = read_ptr; - const auto end = start + frames * m_channels + 1; - const auto result = output.begin() + read * m_channels; - - std::copy(start, end, result); - - ma_pcm_rb_commit_read(m_ringbuffer.get(), frames); - - read += frames; - tries++; - } + m_ringbuffer.Read(output); } void VoiceBuffer::Write(InputBuffer input) noexcept { - const auto total_frames = input.size() / m_channels; - - uint32_t written = 0; - uint32_t tries = 0; - - // Try twice in case of wrap around - while (written < total_frames && tries < 2) { - uint32_t frames = total_frames - written; - float* write_ptr; - - ma_pcm_rb_acquire_write(m_ringbuffer.get(), &frames, reinterpret_cast(&write_ptr)); - - const auto start = input.begin() + written * m_channels; - const auto end = start + frames * m_channels + 1; - - std::copy(start, end, write_ptr); - - ma_pcm_rb_commit_write(m_ringbuffer.get(), frames); - - written += frames; - tries++; - } + m_ringbuffer.Write(input); } void VoiceBuffer::Clear() noexcept { - ma_pcm_rb_reset(m_ringbuffer.get()); + m_ringbuffer.Clear(); } } diff --git a/src/audio/voice/playback/voice_buffer.hpp b/src/audio/voice/playback/voice_buffer.hpp index c0f42f01..473c2b99 100644 --- a/src/audio/voice/playback/voice_buffer.hpp +++ b/src/audio/voice/playback/voice_buffer.hpp @@ -1,6 +1,6 @@ #pragma once -#include +#include "audio/miniaudio/ma_pcm_rb.hpp" namespace AbaddonClient::Audio::Voice::Playback { @@ -13,21 +13,10 @@ class VoiceBuffer { void Clear() noexcept; private: - struct RingBufferDeleter { - void operator()(ma_pcm_rb* ptr) noexcept { - ma_pcm_rb_uninit(ptr); - } - }; + VoiceBuffer(Miniaudio::MaPCMRingBuffer &&ringbuffer, uint32_t buffer_frames) noexcept; - using RingBufferPtr = std::unique_ptr; - VoiceBuffer(RingBufferPtr &&ringbuffer, uint32_t channels, uint32_t buffer_frames) noexcept; - - RingBufferPtr m_ringbuffer; - - uint32_t m_channels; - uint32_t m_buffer_frames; - - bool moved = false; + Miniaudio::MaPCMRingBuffer m_ringbuffer; + const uint32_t m_buffer_frames; }; } From f235bcd80b29453af3934eb0c14121851ea0d00d Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Wed, 22 May 2024 22:57:00 +0300 Subject: [PATCH 15/39] Use VoiceClient and DiscordClient signals in VoiceAudio --- src/abaddon.cpp | 9 +------ src/audio/manager.cpp | 4 +-- src/audio/manager.hpp | 2 +- src/audio/miniaudio/ma_device.hpp | 2 -- src/audio/voice/playback/voice_playback.cpp | 26 +++++++++++++++++-- src/audio/voice/playback/voice_playback.hpp | 16 ++++++++++-- src/audio/voice/voice_audio.cpp | 15 ++++++++--- src/audio/voice/voice_audio.hpp | 8 ++++-- .../channellist/channellisttree.cpp | 6 ----- 9 files changed, 60 insertions(+), 28 deletions(-) diff --git a/src/abaddon.cpp b/src/abaddon.cpp index 8bf95e73..a0c2b2dc 100644 --- a/src/abaddon.cpp +++ b/src/abaddon.cpp @@ -55,7 +55,7 @@ Abaddon::Abaddon() , m_discord(GetSettings().UseMemoryDB) // stupid but easy , m_emojis(GetResPath("/emojis.db")) #ifdef WITH_VOICE - , m_audio(GetSettings().Backends) + , m_audio(GetSettings().Backends, m_discord) #endif { LoadFromSettings(); @@ -81,10 +81,6 @@ Abaddon::Abaddon() #ifdef WITH_VOICE m_discord.signal_voice_connected().connect(sigc::mem_fun(*this, &Abaddon::OnVoiceConnected)); m_discord.signal_voice_disconnected().connect(sigc::mem_fun(*this, &Abaddon::OnVoiceDisconnected)); - m_discord.signal_voice_speaking().connect([this](const VoiceSpeakingData &m) { - spdlog::get("voice")->debug("{} SSRC: {}", m.UserID, m.SSRC); - m_audio.AddSSRC(m.SSRC); - }); #endif m_discord.signal_channel_accessibility_changed().connect([this](Snowflake id, bool accessible) { @@ -487,13 +483,10 @@ void Abaddon::DiscordOnThreadUpdate(const ThreadUpdateData &data) { #ifdef WITH_VOICE void Abaddon::OnVoiceConnected() { - m_audio.StartVoice(); ShowVoiceWindow(); } void Abaddon::OnVoiceDisconnected() { - m_audio.StopVoice(); - m_audio.RemoveAllSSRCs(); if (m_voice_window != nullptr) { m_voice_window->close(); } diff --git a/src/audio/manager.cpp b/src/audio/manager.cpp index 76c4ae30..edcf59d8 100644 --- a/src/audio/manager.cpp +++ b/src/audio/manager.cpp @@ -86,7 +86,7 @@ void mgr_log_callback(void *pUserData, ma_uint32 level, const char *pMessage) { g_free(msg); } -AudioManager::AudioManager(const Glib::ustring &backends_string) +AudioManager::AudioManager(const Glib::ustring &backends_string, DiscordClient &discord) : m_log(spdlog::stdout_color_mt("miniaudio")) { m_ok = true; @@ -126,7 +126,7 @@ AudioManager::AudioManager(const Glib::ustring &backends_string) Enumerate(); #if WITH_VOICE - m_voice.emplace(*m_context); + m_voice.emplace(*m_context, discord); #endif } diff --git a/src/audio/manager.hpp b/src/audio/manager.hpp index d9d32877..6b16e8fc 100644 --- a/src/audio/manager.hpp +++ b/src/audio/manager.hpp @@ -30,7 +30,7 @@ class AudioManager { public: - AudioManager(const Glib::ustring &backends_string); + AudioManager(const Glib::ustring &backends_string, DiscordClient &discord); ~AudioManager(); void AddSSRC(uint32_t ssrc); diff --git a/src/audio/miniaudio/ma_device.hpp b/src/audio/miniaudio/ma_device.hpp index 4255c87a..afd59395 100644 --- a/src/audio/miniaudio/ma_device.hpp +++ b/src/audio/miniaudio/ma_device.hpp @@ -8,7 +8,6 @@ namespace AbaddonClient::Audio::Miniaudio { class MaDevice { public: - static std::optional Create(MaContext &context, ma_device_config &config) noexcept; bool Start() noexcept; @@ -29,7 +28,6 @@ class MaDevice { using DevicePtr = std::unique_ptr; MaDevice(DevicePtr &&device) noexcept; - DevicePtr m_device; }; diff --git a/src/audio/voice/playback/voice_playback.cpp b/src/audio/voice/playback/voice_playback.cpp index c327087c..9fa0c9eb 100644 --- a/src/audio/voice/playback/voice_playback.cpp +++ b/src/audio/voice/playback/voice_playback.cpp @@ -13,8 +13,16 @@ void playback_callback(ma_device* pDevice, void* pOutput, const void* pInput, ma playback->OnAudioPlayback(buffer); } -VoicePlayback::VoicePlayback(Context &context) noexcept : - m_device(context, GetDeviceConfig(), context.GetActivePlaybackID()) {} +VoicePlayback::VoicePlayback(Context &context, DiscordClient &discord) noexcept : + m_device(context, GetDeviceConfig(), context.GetActivePlaybackID()), + m_voice_client(discord.GetVoiceClient()) +{ + m_voice_client.signal_speaking() + .connect(sigc::mem_fun(*this, &VoicePlayback::OnUserSpeaking)); + + discord.signal_voice_user_disconnect() + .connect(sigc::mem_fun(*this, &VoicePlayback::OnUserDisconnect)); +} void VoicePlayback::OnRTPData(ClientID id, std::vector &&data) noexcept { if (m_active) { @@ -29,6 +37,20 @@ void VoicePlayback::OnAudioPlayback(OutputBuffer buffer) noexcept { } } +void VoicePlayback::OnUserSpeaking(const VoiceSpeakingData &speaking_data) noexcept { + m_clients.AddClient(speaking_data.SSRC); + + const auto volume = m_voice_client.GetUserVolume(speaking_data.UserID); + m_clients.SetClientVolume(speaking_data.SSRC, volume); +} + +void VoicePlayback::OnUserDisconnect(Snowflake user_id, Snowflake channel_id) noexcept { + const auto ssrc = m_voice_client.GetSSRCOfUser(user_id); + if (ssrc) { + m_clients.RemoveClient(*ssrc); + } +} + void VoicePlayback::Start() noexcept { m_device.Start(); } diff --git a/src/audio/voice/playback/voice_playback.hpp b/src/audio/voice/playback/voice_playback.hpp index 428034ea..4f9a55fc 100644 --- a/src/audio/voice/playback/voice_playback.hpp +++ b/src/audio/voice/playback/voice_playback.hpp @@ -1,5 +1,11 @@ #pragma once +#include + +#include "discord/snowflake.hpp" +#include "discord/discord.hpp" +#include "discord/voiceclient.hpp" + #include "audio/audio_device.hpp" #include "audio/context.hpp" #include "audio/utils.hpp" @@ -8,11 +14,11 @@ namespace AbaddonClient::Audio::Voice { -class VoicePlayback { +class VoicePlayback : public sigc::trackable { public: using ClientID = Playback::ClientStore::ClientID; - VoicePlayback(Context &context) noexcept; + VoicePlayback(Context &context, DiscordClient &discord) noexcept; void OnRTPData(ClientID id, std::vector &&data) noexcept; @@ -27,8 +33,14 @@ class VoicePlayback { private: void OnAudioPlayback(OutputBuffer buffer) noexcept; + + void OnUserSpeaking(const VoiceSpeakingData &speaking_data) noexcept; + void OnUserDisconnect(Snowflake user_id, Snowflake channel_id) noexcept; + ma_device_config GetDeviceConfig() noexcept; + DiscordVoiceClient &m_voice_client; + AudioDevice m_device; Playback::ClientStore m_clients; diff --git a/src/audio/voice/voice_audio.cpp b/src/audio/voice/voice_audio.cpp index 032e766b..07fb171b 100644 --- a/src/audio/voice/voice_audio.cpp +++ b/src/audio/voice/voice_audio.cpp @@ -5,9 +5,18 @@ namespace AbaddonClient::Audio { using VoicePlayback = Voice::VoicePlayback; using VoiceCapture = Voice::VoiceCapture; -VoiceAudio::VoiceAudio(Context &context) noexcept : - m_playback(context), - m_capture(context) {} +VoiceAudio::VoiceAudio(Context &context, DiscordClient &discord) noexcept : + m_playback(context, discord), + m_capture(context) +{ + auto& voice_client = discord.GetVoiceClient(); + + voice_client.signal_connected() + .connect(sigc::mem_fun(*this, &VoiceAudio::Start)); + + voice_client.signal_disconnected() + .connect(sigc::mem_fun(*this, &VoiceAudio::Stop)); +} void VoiceAudio::Start() noexcept { m_playback.Start(); diff --git a/src/audio/voice/voice_audio.hpp b/src/audio/voice/voice_audio.hpp index 9fffd22f..0ed165f3 100644 --- a/src/audio/voice/voice_audio.hpp +++ b/src/audio/voice/voice_audio.hpp @@ -1,5 +1,9 @@ #pragma once +#include + +#include "discord/discord.hpp" + #include "audio/context.hpp" #include "capture/voice_capture.hpp" @@ -7,12 +11,12 @@ namespace AbaddonClient::Audio { -class VoiceAudio { +class VoiceAudio : public sigc::trackable { public: using VoicePlayback = Voice::VoicePlayback; using VoiceCapture = Voice::VoiceCapture; - VoiceAudio(Context &context) noexcept; + VoiceAudio(Context &context, DiscordClient &discord) noexcept; void Start() noexcept; void Stop() noexcept; diff --git a/src/components/channellist/channellisttree.cpp b/src/components/channellist/channellisttree.cpp index 68705475..4816b423 100644 --- a/src/components/channellist/channellisttree.cpp +++ b/src/components/channellist/channellisttree.cpp @@ -636,12 +636,6 @@ void ChannelListTree::OnVoiceUserDisconnect(Snowflake user_id, Snowflake channel if (auto iter = GetIteratorForRowFromIDOfType(user_id, RenderType::VoiceParticipant)) { m_model->erase(iter); } - - auto& abaddon = Abaddon::Get(); - auto ssrc = abaddon.GetDiscordClient().GetSSRCOfUser(user_id); - if (ssrc) { - abaddon.GetAudio().RemoveSSRC(*ssrc); - } } void ChannelListTree::OnVoiceStateSet(Snowflake user_id, Snowflake channel_id, VoiceStateFlags flags) { From 631eb03da9c14b25309e20cb7937ba1767d25cee Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Tue, 28 May 2024 16:19:17 +0300 Subject: [PATCH 16/39] Wrap ma_log --- src/audio/manager.cpp | 13 ++++++++----- src/audio/manager.hpp | 4 +++- src/audio/miniaudio/ma_log.cpp | 28 ++++++++++++++++++++++++++++ src/audio/miniaudio/ma_log.hpp | 27 +++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 src/audio/miniaudio/ma_log.cpp create mode 100644 src/audio/miniaudio/ma_log.hpp diff --git a/src/audio/manager.cpp b/src/audio/manager.cpp index edcf59d8..dc9614d5 100644 --- a/src/audio/manager.cpp +++ b/src/audio/manager.cpp @@ -87,12 +87,11 @@ void mgr_log_callback(void *pUserData, ma_uint32 level, const char *pMessage) { } AudioManager::AudioManager(const Glib::ustring &backends_string, DiscordClient &discord) - : m_log(spdlog::stdout_color_mt("miniaudio")) { + : m_log(spdlog::stdout_color_mt("miniaudio")), + m_ma_log(AbaddonClient::Audio::Miniaudio::MaLog::Create()) +{ m_ok = true; - ma_log_init(nullptr, &m_ma_log); - ma_log_register_callback(&m_ma_log, ma_log_callback_init(mgr_log_callback, m_log.get())); - #ifdef WITH_RNNOISE RNNoiseInitialize(); #endif @@ -107,7 +106,11 @@ AudioManager::AudioManager(const Glib::ustring &backends_string, DiscordClient & opus_encoder_ctl(m_encoder, OPUS_SET_BITRATE(64000)); auto ctx_cfg = ma_context_config_init(); - ctx_cfg.pLog = &m_ma_log; + + if (m_ma_log) { + m_ma_log->RegisterCallback(ma_log_callback_init(mgr_log_callback, m_log.get())); + ctx_cfg.pLog = &m_ma_log->GetInternal(); + } ma_backend *pBackends = nullptr; ma_uint32 backendCount = 0; diff --git a/src/audio/manager.hpp b/src/audio/manager.hpp index 6b16e8fc..f1043ead 100644 --- a/src/audio/manager.hpp +++ b/src/audio/manager.hpp @@ -26,6 +26,8 @@ #include "voice/voice_audio.hpp" #endif +#include "miniaudio/ma_log.hpp" + // clang-format on class AudioManager { @@ -173,7 +175,7 @@ class AudioManager { #endif std::atomic m_rtp_timestamp = 0; - ma_log m_ma_log; + std::optional m_ma_log; std::shared_ptr m_log; public: diff --git a/src/audio/miniaudio/ma_log.cpp b/src/audio/miniaudio/ma_log.cpp new file mode 100644 index 00000000..27614c98 --- /dev/null +++ b/src/audio/miniaudio/ma_log.cpp @@ -0,0 +1,28 @@ +#include "ma_log.hpp" + +namespace AbaddonClient::Audio::Miniaudio { + +MaLog::MaLog(LogPtr &&log) noexcept : + m_log(std::move(log)) {} + +std::optional MaLog::Create() noexcept { + LogPtr log = LogPtr(new ma_log); + + const auto result = ma_log_init(nullptr, log.get()); + if (result != MA_SUCCESS) { + spdlog::get("audio")->error("Failed to create log: {}", ma_result_description(result)); + return std::nullopt; + } + + return MaLog(std::move(log)); +} + +bool MaLog::RegisterCallback(ma_log_callback callback) noexcept { + return ma_log_register_callback(m_log.get(), callback) == MA_SUCCESS; +} + +ma_log& MaLog::GetInternal() noexcept { + return *m_log; +} + +} diff --git a/src/audio/miniaudio/ma_log.hpp b/src/audio/miniaudio/ma_log.hpp new file mode 100644 index 00000000..e8a99ed2 --- /dev/null +++ b/src/audio/miniaudio/ma_log.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include + +namespace AbaddonClient::Audio::Miniaudio { + +class MaLog { +public: + static std::optional Create() noexcept; + + bool RegisterCallback(ma_log_callback callback) noexcept; + + ma_log& GetInternal() noexcept; +private: + struct LogDeleter { + void operator()(ma_log* ptr) noexcept { + ma_log_uninit(ptr); + } + }; + + using LogPtr = std::unique_ptr; + MaLog(LogPtr &&log) noexcept; + + LogPtr m_log; +}; + +} From 09cff96ba672b07aeccfd7e2412f6e1bdb81dcf6 Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Wed, 29 May 2024 13:06:22 +0300 Subject: [PATCH 17/39] Remove no longer used stuff in AudioManager --- src/audio/manager.cpp | 243 +----------------------------------- src/audio/manager.hpp | 85 ------------- src/discord/voiceclient.cpp | 1 - 3 files changed, 3 insertions(+), 326 deletions(-) diff --git a/src/audio/manager.cpp b/src/audio/manager.cpp index dc9614d5..c69f2869 100644 --- a/src/audio/manager.cpp +++ b/src/audio/manager.cpp @@ -7,7 +7,6 @@ #include "manager.hpp" #include "abaddon.hpp" -#include #include #include #include @@ -15,53 +14,6 @@ #include // clang-format on -const uint8_t *StripRTPExtensionHeader(const uint8_t *buf, int num_bytes, size_t &outlen) { - if (buf[0] == 0xbe && buf[1] == 0xde && num_bytes > 4) { - uint64_t offset = 4 + 4 * ((buf[2] << 8) | buf[3]); - - outlen = num_bytes - offset; - return buf + offset; - } - outlen = num_bytes; - return buf; -} - -void data_callback(ma_device *pDevice, void *pOutput, const void *pInput, ma_uint32 frameCount) { - AudioManager *mgr = reinterpret_cast(pDevice->pUserData); - if (mgr == nullptr) return; - std::lock_guard _(mgr->m_mutex); - - auto *pOutputF32 = static_cast(pOutput); - for (auto &[ssrc, pair] : mgr->m_sources) { - double volume = 1.0; - if (const auto vol_it = mgr->m_volume_ssrc.find(ssrc); vol_it != mgr->m_volume_ssrc.end()) { - volume = vol_it->second; - } - auto &buf = pair.first; - const size_t n = std::min(static_cast(buf.size()), static_cast(frameCount * 2ULL)); - for (size_t i = 0; i < n; i++) { - pOutputF32[i] += volume * buf[i] / 32768.F; - } - buf.erase(buf.begin(), buf.begin() + n); - } -} - -void capture_data_callback(ma_device *pDevice, void *pOutput, const void *pInput, ma_uint32 frameCount) { - auto *mgr = reinterpret_cast(pDevice->pUserData); - if (mgr == nullptr) return; - - mgr->OnCapturedPCM(static_cast(pInput), frameCount); - - /* - * You can simply increment it by 480 in UDPSocket::SendEncrypted but this is wrong - * The timestamp is supposed to be strictly linear eg. if there's discontinuous - * transmission for 1 second then the timestamp should be 48000 greater than the - * last packet. So it's incremented here because this is fired 100x per second - * and is always called in sync with UDPSocket::SendEncrypted - */ - mgr->m_rtp_timestamp += 480; -} - void mgr_log_callback(void *pUserData, ma_uint32 level, const char *pMessage) { auto *log = static_cast(pUserData); @@ -92,19 +44,6 @@ AudioManager::AudioManager(const Glib::ustring &backends_string, DiscordClient & { m_ok = true; -#ifdef WITH_RNNOISE - RNNoiseInitialize(); -#endif - - int err; - m_encoder = opus_encoder_create(48000, 2, OPUS_APPLICATION_VOIP, &err); - if (err != OPUS_OK) { - spdlog::get("audio")->error("failed to initialize opus encoder: {}", err); - m_ok = false; - return; - } - opus_encoder_ctl(m_encoder, OPUS_SET_BITRATE(64000)); - auto ctx_cfg = ma_context_config_init(); if (m_ma_log) { @@ -112,18 +51,13 @@ AudioManager::AudioManager(const Glib::ustring &backends_string, DiscordClient & ctx_cfg.pLog = &m_ma_log->GetInternal(); } - ma_backend *pBackends = nullptr; - ma_uint32 backendCount = 0; - - std::vector backends_vec; + std::vector backends; if (!backends_string.empty()) { spdlog::get("audio")->debug("Using backends list: {}", std::string(backends_string)); - backends_vec = ParseBackendsList(backends_string); - pBackends = backends_vec.data(); - backendCount = static_cast(backends_vec.size()); + backends = ParseBackendsList(backends_string); } - m_context = AbaddonClient::Audio::Context::Create(std::move(ctx_cfg), backends_vec); + m_context = AbaddonClient::Audio::Context::Create(std::move(ctx_cfg), backends); if (m_context) { Enumerate(); @@ -136,43 +70,6 @@ AudioManager::AudioManager(const Glib::ustring &backends_string, DiscordClient & Glib::signal_timeout().connect(sigc::mem_fun(*this, &AudioManager::DecayVolumeMeters), 40); } -AudioManager::~AudioManager() { - RemoveAllSSRCs(); - -#ifdef WITH_RNNOISE - RNNoiseUninitialize(); -#endif -} - -void AudioManager::StartVoice() { - m_voice->Start(); -} - -void AudioManager::StopVoice() { - m_voice->Stop(); -} - -void AudioManager::AddSSRC(uint32_t ssrc) { - std::lock_guard _(m_mutex); - m_voice->GetPlayback().GetClientStore().AddClient(ssrc); -} - -void AudioManager::RemoveSSRC(uint32_t ssrc) { - std::lock_guard _(m_mutex); - - m_voice->GetPlayback().GetClientStore().RemoveClient(ssrc); -} - -void AudioManager::RemoveAllSSRCs() { - spdlog::get("audio")->info("removing all ssrc"); - std::lock_guard _(m_mutex); - m_voice->GetPlayback().GetClientStore().Clear(); -} - -void AudioManager::SetOpusBuffer(uint8_t *ptr) { - m_opus_buffer = ptr; -} - void AudioManager::FeedMeOpus(uint32_t ssrc, std::vector &&data) { m_voice->GetPlayback().OnRTPData(ssrc, std::move(data)); } @@ -283,91 +180,6 @@ void AudioManager::Enumerate() { ); } -void AudioManager::OnCapturedPCM(const int16_t *pcm, ma_uint32 frames) { - if (m_opus_buffer == nullptr || !m_should_capture) return; - - const double gain = m_capture_gain; - - std::vector new_pcm(pcm, pcm + frames * 2); - for (auto &val : new_pcm) { - const int32_t unclamped = static_cast(val * gain); - val = std::clamp(unclamped, INT16_MIN, INT16_MAX); - } - - if (m_mix_mono) { - for (size_t i = 0; i < frames * 2; i += 2) { - const int sample_L = new_pcm[i]; - const int sample_R = new_pcm[i + 1]; - const int16_t mixed = static_cast((sample_L + sample_R) / 2); - new_pcm[i] = mixed; - new_pcm[i + 1] = mixed; - } - } - - UpdateCaptureVolume(new_pcm.data(), frames); - - static std::array denoised_L; - static std::array denoised_R; - - bool m_rnnoise_passed = false; -#ifdef WITH_RNNOISE - if (m_vad_method == VADMethod::RNNoise || m_enable_noise_suppression) { - m_rnnoise_passed = CheckVADRNNoise(new_pcm.data(), denoised_L.data(), denoised_R.data()); - } -#endif - - switch (m_vad_method) { - case VADMethod::Gate: - if (!CheckVADVoiceGate()) return; - break; -#ifdef WITH_RNNOISE - case VADMethod::RNNoise: - if (!m_rnnoise_passed) return; - break; -#endif - } - - m_enc_mutex.lock(); - int payload_len = -1; - - if (m_enable_noise_suppression) { - static std::array denoised_interleaved; - for (size_t i = 0; i < 480; i++) { - denoised_interleaved[i * 2] = static_cast(denoised_L[i]); - } - for (size_t i = 0; i < 480; i++) { - denoised_interleaved[i * 2 + 1] = static_cast(denoised_R[i]); - } - payload_len = opus_encode(m_encoder, denoised_interleaved.data(), 480, static_cast(m_opus_buffer), 1275); - } else { - payload_len = opus_encode(m_encoder, new_pcm.data(), 480, static_cast(m_opus_buffer), 1275); - } - - m_enc_mutex.unlock(); - if (payload_len < 0) { - spdlog::get("audio")->error("encoding error: {}", payload_len); - } else { - m_signal_opus_packet.emit(payload_len); - } -} - -void AudioManager::UpdateReceiveVolume(uint32_t ssrc, const int16_t *pcm, int frames) { - std::lock_guard _(m_vol_mtx); - - auto &meter = m_volumes[ssrc]; - for (int i = 0; i < frames * 2; i += 2) { - const int amp = std::abs(pcm[i]); - meter = std::max(meter, std::abs(amp) / 32768.0); - } -} - -void AudioManager::UpdateCaptureVolume(const int16_t *pcm, ma_uint32 frames) { - for (ma_uint32 i = 0; i < frames * 2; i += 2) { - const int amp = std::abs(pcm[i]); - m_capture_peak_meter = std::max(m_capture_peak_meter.load(std::memory_order_relaxed), amp); - } -} - bool AudioManager::DecayVolumeMeters() { m_voice->GetCapture().GetPeakMeter().Decay(); m_voice->GetPlayback().GetClientStore().DecayPeakMeters(); @@ -375,55 +187,6 @@ bool AudioManager::DecayVolumeMeters() { return true; } -bool AudioManager::CheckVADVoiceGate() { - return m_capture_peak_meter / 32768.0 > m_capture_gate; -} - -#ifdef WITH_RNNOISE -bool AudioManager::CheckVADRNNoise(const int16_t *pcm, float *denoised_left, float *denoised_right) { - // use left channel for vad, only denoise right if noise suppression enabled - std::unique_lock _(m_rnn_mutex); - - static float rnnoise_input[480]; - for (size_t i = 0; i < 480; i++) { - rnnoise_input[i] = static_cast(pcm[i * 2]); - } - m_vad_prob = std::max(m_vad_prob.load(), rnnoise_process_frame(m_rnnoise[0], denoised_left, rnnoise_input)); - - if (m_enable_noise_suppression) { - for (size_t i = 0; i < 480; i++) { - rnnoise_input[i] = static_cast(pcm[i * 2 + 1]); - } - rnnoise_process_frame(m_rnnoise[1], denoised_right, rnnoise_input); - } - - return m_vad_prob > m_prob_threshold; -} - -void AudioManager::RNNoiseInitialize() { - spdlog::get("audio")->debug("Initializing RNNoise"); - RNNoiseUninitialize(); - std::unique_lock _(m_rnn_mutex); - m_rnnoise[0] = rnnoise_create(nullptr); - m_rnnoise[1] = rnnoise_create(nullptr); - const auto expected = rnnoise_get_frame_size(); - if (expected != 480) { - spdlog::get("audio")->warn("RNNoise expects a frame count other than 480"); - } -} - -void AudioManager::RNNoiseUninitialize() { - if (m_rnnoise[0] != nullptr) { - spdlog::get("audio")->debug("Uninitializing RNNoise"); - std::unique_lock _(m_rnn_mutex); - rnnoise_destroy(m_rnnoise[0]); - rnnoise_destroy(m_rnnoise[1]); - m_rnnoise[0] = nullptr; - m_rnnoise[1] = nullptr; - } -} -#endif - bool AudioManager::OK() const { return m_ok; } diff --git a/src/audio/manager.hpp b/src/audio/manager.hpp index f1043ead..0f8e551b 100644 --- a/src/audio/manager.hpp +++ b/src/audio/manager.hpp @@ -2,24 +2,13 @@ #ifdef WITH_MINIAUDIO // clang-format off -#include -#include -#include #include -#include -#include -#include -#include #include #include #include #include #include -#ifdef WITH_RNNOISE -#include -#endif - #include "devices.hpp" #ifdef WITH_VOICE @@ -33,18 +22,9 @@ class AudioManager { public: AudioManager(const Glib::ustring &backends_string, DiscordClient &discord); - ~AudioManager(); - - void AddSSRC(uint32_t ssrc); - void RemoveSSRC(uint32_t ssrc); - void RemoveAllSSRCs(); - void SetOpusBuffer(uint8_t *ptr); void FeedMeOpus(uint32_t ssrc, std::vector &&data); - void StartVoice(); - void StopVoice(); - void SetPlaybackDevice(const Gtk::TreeModel::iterator &iter); void SetCaptureDevice(const Gtk::TreeModel::iterator &iter); @@ -101,88 +81,23 @@ class AudioManager { bool GetMixMono() const; private: - void OnCapturedPCM(const int16_t *pcm, ma_uint32 frames); - - void UpdateReceiveVolume(uint32_t ssrc, const int16_t *pcm, int frames); - void UpdateCaptureVolume(const int16_t *pcm, ma_uint32 frames); - std::atomic m_capture_peak_meter = 0; - bool DecayVolumeMeters(); - bool CheckVADVoiceGate(); - -#ifdef WITH_RNNOISE - bool CheckVADRNNoise(const int16_t *pcm, float *denoised_left, float *denoised_right); - - void RNNoiseInitialize(); - void RNNoiseUninitialize(); -#endif - - friend void data_callback(ma_device *, void *, const void *, ma_uint32); - friend void capture_data_callback(ma_device *, void *, const void *, ma_uint32); - - std::thread m_thread; - bool m_ok; - // playback - ma_device m_playback_device; - bool m_playback_device_ready = false; - // capture - ma_device m_capture_device; - bool m_capture_device_ready = false; - std::optional m_context; - mutable std::mutex m_mutex; - mutable std::mutex m_enc_mutex; - -#ifdef WITH_RNNOISE - mutable std::mutex m_rnn_mutex; -#endif - - std::unordered_map, OpusDecoder *>> m_sources; - - OpusEncoder *m_encoder; - - uint8_t *m_opus_buffer = nullptr; - - std::atomic m_should_capture = true; - std::atomic m_should_playback = true; - - std::atomic m_capture_gate = 0.0; - std::atomic m_capture_gain = 1.0; - std::atomic m_prob_threshold = 0.5; - std::atomic m_vad_prob = 0.0; - std::atomic m_enable_noise_suppression = false; - std::atomic m_mix_mono = false; - - std::unordered_set m_muted_ssrcs; - std::unordered_map m_volume_ssrc; - - mutable std::mutex m_vol_mtx; - std::unordered_map m_volumes; - AudioDevices m_devices; #ifdef WITH_VOICE std::optional m_voice; #endif - VADMethod m_vad_method; -#ifdef WITH_RNNOISE - DenoiseState *m_rnnoise[2]; -#endif - std::atomic m_rtp_timestamp = 0; - std::optional m_ma_log; std::shared_ptr m_log; public: using type_signal_opus_packet = sigc::signal; AbaddonClient::Audio::Voice::VoiceCapture::CaptureSignal signal_opus_packet(); - -private: - type_signal_opus_packet m_signal_opus_packet; }; #endif diff --git a/src/discord/voiceclient.cpp b/src/discord/voiceclient.cpp index ad8425d0..7e64d5e1 100644 --- a/src/discord/voiceclient.cpp +++ b/src/discord/voiceclient.cpp @@ -153,7 +153,6 @@ DiscordVoiceClient::DiscordVoiceClient() // idle or else singleton deadlock Glib::signal_idle().connect_once([this]() { auto &audio = Abaddon::Get().GetAudio(); - audio.SetOpusBuffer(m_opus_buffer.data()); audio.signal_opus_packet().connect([this](const std::vector opus) { if (IsConnected()) { m_udp.SendEncrypted(opus.data(), opus.size()); From dffe44e95050ae66438ab9fcd63a819f50695dfb Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Sun, 2 Jun 2024 15:18:25 +0300 Subject: [PATCH 18/39] Clean up AudioManager and ifdef conditions, expose VoiceAudio --- src/abaddon.cpp | 32 ++++- src/audio/devices.hpp | 2 +- src/audio/manager.cpp | 151 +--------------------- src/audio/manager.hpp | 70 ++-------- src/audio/voice/capture/voice_effects.cpp | 36 +++++- src/audio/voice/capture/voice_effects.hpp | 7 +- src/discord/voiceclient.cpp | 14 +- src/windows/voicesettingswindow.cpp | 51 +++++--- src/windows/voicewindow.cpp | 80 ++++++------ src/windows/voicewindow.hpp | 9 ++ 10 files changed, 164 insertions(+), 288 deletions(-) diff --git a/src/abaddon.cpp b/src/abaddon.cpp index a0c2b2dc..dade8af1 100644 --- a/src/abaddon.cpp +++ b/src/abaddon.cpp @@ -99,7 +99,7 @@ Abaddon::Abaddon() } #ifdef WITH_VOICE - m_audio.SetVADMethod(GetSettings().VAD); + m_audio.GetVoice().GetCapture().GetEffects().SetVADMethod(GetSettings().VAD); #endif } @@ -500,23 +500,47 @@ void Abaddon::ShowVoiceWindow() { wnd->signal_mute().connect([this](bool is_mute) { m_discord.SetVoiceMuted(is_mute); - m_audio.SetCapture(!is_mute); + m_audio.GetVoice().GetCapture().SetActive(!is_mute); }); wnd->signal_deafen().connect([this](bool is_deaf) { m_discord.SetVoiceDeafened(is_deaf); - m_audio.SetPlayback(!is_deaf); + m_audio.GetVoice().GetPlayback().SetActive(!is_deaf); }); wnd->signal_mute_user_cs().connect([this](Snowflake id, bool is_mute) { if (const auto ssrc = m_discord.GetSSRCOfUser(id); ssrc.has_value()) { - m_audio.SetMuteSSRC(*ssrc, is_mute); + m_audio.GetVoice().GetPlayback().GetClientStore().SetClientMute(*ssrc, is_mute); } }); wnd->signal_user_volume_changed().connect([this](Snowflake id, double volume) { auto &vc = m_discord.GetVoiceClient(); vc.SetUserVolume(id, volume); + + if (const auto ssrc = m_discord.GetSSRCOfUser(id); ssrc.has_value()) { + m_audio.GetVoice().GetPlayback().GetClientStore().SetClientVolume(*ssrc, volume); + } + }); + + wnd->signal_playback_device_changed().connect([this](const Gtk::TreeModel::iterator &iter) { + auto device_id = m_audio.GetDevices().GetPlaybackDeviceIDFromModel(iter); + if (!device_id) { + spdlog::get("audio")->error("Requested ID from iterator is invalid"); + return; + } + + m_audio.GetVoice().GetPlayback().SetPlaybackDevice(*device_id); + }); + + wnd->signal_capture_device_changed().connect([this](const Gtk::TreeModel::iterator &iter) { + auto device_id = m_audio.GetDevices().GetCaptureDeviceIDFromModel(iter); + if (!device_id) { + spdlog::get("audio")->error("Requested ID from iterator is invalid"); + return; + } + + m_audio.GetVoice().GetCapture().SetCaptureDevice(*device_id); }); wnd->set_position(Gtk::WIN_POS_CENTER); diff --git a/src/audio/devices.hpp b/src/audio/devices.hpp index d602cb62..8c7d0523 100644 --- a/src/audio/devices.hpp +++ b/src/audio/devices.hpp @@ -1,5 +1,5 @@ #pragma once -#ifdef WITH_VOICE +#ifdef WITH_MINIAUDIO // clang-format off diff --git a/src/audio/manager.cpp b/src/audio/manager.cpp index c69f2869..b0a28cdc 100644 --- a/src/audio/manager.cpp +++ b/src/audio/manager.cpp @@ -42,8 +42,6 @@ AudioManager::AudioManager(const Glib::ustring &backends_string, DiscordClient & : m_log(spdlog::stdout_color_mt("miniaudio")), m_ma_log(AbaddonClient::Audio::Miniaudio::MaLog::Create()) { - m_ok = true; - auto ctx_cfg = ma_context_config_init(); if (m_ma_log) { @@ -68,95 +66,8 @@ AudioManager::AudioManager(const Glib::ustring &backends_string, DiscordClient & } Glib::signal_timeout().connect(sigc::mem_fun(*this, &AudioManager::DecayVolumeMeters), 40); -} - -void AudioManager::FeedMeOpus(uint32_t ssrc, std::vector &&data) { - m_voice->GetPlayback().OnRTPData(ssrc, std::move(data)); -} - -void AudioManager::SetPlaybackDevice(const Gtk::TreeModel::iterator &iter) { - auto device_id = m_devices.GetPlaybackDeviceIDFromModel(iter); - if (!device_id) { - spdlog::get("audio")->error("Requested ID from iterator is invalid"); - return; - } - - m_voice->GetPlayback().SetPlaybackDevice(*device_id); -} - -void AudioManager::SetCaptureDevice(const Gtk::TreeModel::iterator &iter) { - auto device_id = m_devices.GetCaptureDeviceIDFromModel(iter); - if (!device_id) { - spdlog::get("audio")->error("Requested ID from iterator is invalid"); - return; - } - - m_voice->GetCapture().SetCaptureDevice(*device_id); -} - -void AudioManager::SetCapture(bool capture) { - m_voice->GetCapture().SetActive(capture); -} - -void AudioManager::SetPlayback(bool playback) { - m_voice->GetPlayback().SetActive(playback); -} - -void AudioManager::SetCaptureGate(double gate) { - m_voice->GetCapture().GetEffects().GetGate().VADThreshold = gate; -} - -void AudioManager::SetCaptureGain(double gain) { - m_voice->GetCapture().Gain = gain; -} - -double AudioManager::GetCaptureGate() const noexcept { - return m_voice->GetCapture().GetEffects().GetGate().VADThreshold; -} - -double AudioManager::GetCaptureGain() const noexcept { - return m_voice->GetCapture().Gain; -} - -void AudioManager::SetMuteSSRC(uint32_t ssrc, bool mute) { - m_voice->GetPlayback().GetClientStore().SetClientMute(ssrc, mute); -} - -void AudioManager::SetVolumeSSRC(uint32_t ssrc, double volume) { - m_voice->GetPlayback().GetClientStore().SetClientVolume(ssrc, volume); -} - -double AudioManager::GetVolumeSSRC(uint32_t ssrc) const { - return m_voice->GetPlayback().GetClientStore().GetClientVolume(ssrc); -} - -void AudioManager::SetEncodingApplication(int application) { - const auto _application = static_cast(application); - m_voice->GetCapture().GetEncoder()->value().SetEncodingApplication(_application); -} - -int AudioManager::GetEncodingApplication() { - const auto application = m_voice->GetCapture().GetEncoder()->value().GetEncodingApplication(); - return static_cast(application); -} - -void AudioManager::SetSignalHint(int signal) { - const auto _signal = static_cast(signal); - m_voice->GetCapture().GetEncoder()->value().SetSignalHint(_signal); -} - -int AudioManager::GetSignalHint() { - const auto hint = m_voice->GetCapture().GetEncoder()->value().GetSignalHint(); - return static_cast(hint); -} - -void AudioManager::SetBitrate(int bitrate) { - m_voice->GetCapture().GetEncoder()->value().SetBitrate(bitrate); -} - -int AudioManager::GetBitrate() { - return m_voice->GetCapture().GetEncoder()->value().GetBitrate(); + m_ok = true; } void AudioManager::Enumerate() { @@ -191,39 +102,10 @@ bool AudioManager::OK() const { return m_ok; } -double AudioManager::GetCaptureVolumeLevel() const noexcept { - return m_voice->GetCapture().GetPeakMeter().GetPeak(); -} - -double AudioManager::GetSSRCVolumeLevel(uint32_t ssrc) const noexcept { - return m_voice->GetPlayback().GetClientStore().GetClientPeakVolume(ssrc); -} - AudioDevices &AudioManager::GetDevices() { return m_devices; } -uint32_t AudioManager::GetRTPTimestamp() const noexcept { - return m_voice->GetCapture().GetRTPTimestamp(); -} - -void AudioManager::SetVADMethod(const std::string &method) { - spdlog::get("audio")->debug("Setting VAD method to {}", method); - m_voice->GetCapture().GetEffects().SetVADMethod(method); -} - -void AudioManager::SetVADMethod(VADMethod method) { - const auto method_int = static_cast(method); - spdlog::get("audio")->debug("Setting VAD method to enum {}", method_int); - - m_voice->GetCapture().GetEffects().SetVADMethod(method_int); -} - -AudioManager::VADMethod AudioManager::GetVADMethod() const { - const auto method = m_voice->GetCapture().GetEffects().GetVADMethod(); - return static_cast(method); -} - std::vector AudioManager::ParseBackendsList(const Glib::ustring &list) { auto regex = Glib::Regex::create(";"); const std::vector split = regex->split(list); @@ -246,38 +128,17 @@ std::vector AudioManager::ParseBackendsList(const Glib::ustring &lis return backends; } -#ifdef WITH_RNNOISE -float AudioManager::GetCurrentVADProbability() const { - return m_voice->GetCapture().GetEffects().GetNoise().GetPeakMeter().GetPeak(); -} - -double AudioManager::GetRNNProbThreshold() const { - return m_voice->GetCapture().GetEffects().GetNoise().VADThreshold; -} +#ifdef WITH_VOICE -void AudioManager::SetRNNProbThreshold(double value) { - m_voice->GetCapture().GetEffects().GetNoise().VADThreshold = value; +AbaddonClient::Audio::VoiceAudio& AudioManager::GetVoice() noexcept { + return *m_voice; } -void AudioManager::SetSuppressNoise(bool value) { - m_voice->GetCapture().SuppressNoise = value; +const AbaddonClient::Audio::VoiceAudio& AudioManager::GetVoice() const noexcept { + return *m_voice; } -bool AudioManager::GetSuppressNoise() const { - return m_voice->GetCapture().SuppressNoise; -} #endif -void AudioManager::SetMixMono(bool value) { - m_voice->GetCapture().MixMono = value; -} - -bool AudioManager::GetMixMono() const { - return m_voice->GetCapture().MixMono; -} - -AbaddonClient::Audio::Voice::VoiceCapture::CaptureSignal AudioManager::signal_opus_packet() { - return m_voice->GetCapture().GetCaptureSignal(); -} #endif diff --git a/src/audio/manager.hpp b/src/audio/manager.hpp index 0f8e551b..fb62aec6 100644 --- a/src/audio/manager.hpp +++ b/src/audio/manager.hpp @@ -23,81 +23,31 @@ class AudioManager { public: AudioManager(const Glib::ustring &backends_string, DiscordClient &discord); - void FeedMeOpus(uint32_t ssrc, std::vector &&data); - - void SetPlaybackDevice(const Gtk::TreeModel::iterator &iter); - void SetCaptureDevice(const Gtk::TreeModel::iterator &iter); - - void SetCapture(bool capture); - void SetPlayback(bool playback); - - void SetCaptureGate(double gate); - void SetCaptureGain(double gain); - double GetCaptureGate() const noexcept; - double GetCaptureGain() const noexcept; - - void SetMuteSSRC(uint32_t ssrc, bool mute); - void SetVolumeSSRC(uint32_t ssrc, double volume); - double GetVolumeSSRC(uint32_t ssrc) const; - - void SetEncodingApplication(int application); - int GetEncodingApplication(); - void SetSignalHint(int signal); - int GetSignalHint(); - void SetBitrate(int bitrate); - int GetBitrate(); - - void Enumerate(); - +#if WITH_VOICE + AbaddonClient::Audio::VoiceAudio& GetVoice() noexcept; + const AbaddonClient::Audio::VoiceAudio& GetVoice() const noexcept; +#endif bool OK() const; - double GetCaptureVolumeLevel() const noexcept; - double GetSSRCVolumeLevel(uint32_t ssrc) const noexcept; - AudioDevices &GetDevices(); - uint32_t GetRTPTimestamp() const noexcept; - - enum class VADMethod { - Gate, - RNNoise, - }; - - void SetVADMethod(const std::string &method); - void SetVADMethod(VADMethod method); - VADMethod GetVADMethod() const; - +private: + void Enumerate(); static std::vector ParseBackendsList(const Glib::ustring &list); -#ifdef WITH_RNNOISE - float GetCurrentVADProbability() const; - double GetRNNProbThreshold() const; - void SetRNNProbThreshold(double value); - void SetSuppressNoise(bool value); - bool GetSuppressNoise() const; -#endif - - void SetMixMono(bool value); - bool GetMixMono() const; - -private: bool DecayVolumeMeters(); - bool m_ok; + AudioDevices m_devices; std::optional m_context; + std::optional m_ma_log; - AudioDevices m_devices; + std::shared_ptr m_log; #ifdef WITH_VOICE std::optional m_voice; #endif - std::optional m_ma_log; - std::shared_ptr m_log; - -public: - using type_signal_opus_packet = sigc::signal; - AbaddonClient::Audio::Voice::VoiceCapture::CaptureSignal signal_opus_packet(); + bool m_ok = false; }; #endif diff --git a/src/audio/voice/capture/voice_effects.cpp b/src/audio/voice/capture/voice_effects.cpp index 2f907bba..c30fa9a5 100644 --- a/src/audio/voice/capture/voice_effects.cpp +++ b/src/audio/voice/capture/voice_effects.cpp @@ -23,6 +23,34 @@ void VoiceEffects::Denoise(OutputBuffer buffer) noexcept { #endif } +void VoiceEffects::SetCurrentThreshold(float threshold) noexcept { + switch (m_vad_method) { + case VADMethod::Gate: { + m_gate.VADThreshold = threshold; + } break; +#ifdef WITH_RNNOISE + case VADMethod::RNNoise: { + m_noise.VADThreshold = threshold; + } break; +#endif + } +} + +float VoiceEffects::GetCurrentThreshold() const noexcept { + switch (m_vad_method) { + case VADMethod::Gate: { + return m_gate.VADThreshold; + } break; +#ifdef WITH_RNNOISE + case VADMethod::RNNoise: { + return m_noise.VADThreshold; + } break; +#endif + } + + return 0.0f; +} + void VoiceEffects::SetVADMethod(const std::string &method) noexcept { if (method == "gate") { m_vad_method = VADMethod::Gate; @@ -37,12 +65,12 @@ void VoiceEffects::SetVADMethod(const std::string &method) noexcept { } } -void VoiceEffects::SetVADMethod(int method) noexcept { - m_vad_method = static_cast(method); +void VoiceEffects::SetVADMethod(VADMethod method) noexcept { + m_vad_method = method; } -int VoiceEffects::GetVADMethod() const noexcept { - return static_cast(m_vad_method); +VADMethod VoiceEffects::GetVADMethod() const noexcept { + return m_vad_method; } Effects::Gate& VoiceEffects::GetGate() noexcept { diff --git a/src/audio/voice/capture/voice_effects.hpp b/src/audio/voice/capture/voice_effects.hpp index 1694192b..218e0a1b 100644 --- a/src/audio/voice/capture/voice_effects.hpp +++ b/src/audio/voice/capture/voice_effects.hpp @@ -22,9 +22,12 @@ class VoiceEffects { bool PassesVAD(InputBuffer buffer, float current_volume) noexcept; void Denoise(OutputBuffer buffer) noexcept; + void SetCurrentThreshold(float threshold) noexcept; + float GetCurrentThreshold() const noexcept; + void SetVADMethod(const std::string &method) noexcept; - void SetVADMethod(int method) noexcept; - int GetVADMethod() const noexcept; + void SetVADMethod(VADMethod method) noexcept; + VADMethod GetVADMethod() const noexcept; Effects::Gate& GetGate() noexcept; const Effects::Gate& GetGate() const noexcept; diff --git a/src/discord/voiceclient.cpp b/src/discord/voiceclient.cpp index 7e64d5e1..333bcfb7 100644 --- a/src/discord/voiceclient.cpp +++ b/src/discord/voiceclient.cpp @@ -50,8 +50,7 @@ void UDPSocket::SetSSRC(uint32_t ssrc) { void UDPSocket::SendEncrypted(const uint8_t *data, size_t len) { m_sequence++; - const uint32_t timestamp = Abaddon::Get().GetAudio().GetRTPTimestamp(); - + const uint32_t timestamp = Abaddon::Get().GetAudio().GetVoice().GetCapture().GetRTPTimestamp(); std::vector rtp(12 + len + crypto_secretbox_MACBYTES, 0); rtp[0] = 0x80; // ver 2 rtp[1] = 0x78; // payload type 0x78 @@ -152,8 +151,8 @@ DiscordVoiceClient::DiscordVoiceClient() // idle or else singleton deadlock Glib::signal_idle().connect_once([this]() { - auto &audio = Abaddon::Get().GetAudio(); - audio.signal_opus_packet().connect([this](const std::vector opus) { + auto &capture = Abaddon::Get().GetAudio().GetVoice().GetCapture(); + capture.GetCaptureSignal().connect([this](const std::vector opus) { if (IsConnected()) { m_udp.SendEncrypted(opus.data(), opus.size()); } @@ -221,9 +220,6 @@ void DiscordVoiceClient::SetUserID(Snowflake id) { void DiscordVoiceClient::SetUserVolume(Snowflake id, float volume) { m_user_volumes[id] = volume; - if (const auto ssrc = GetSSRCOfUser(id); ssrc.has_value()) { - Abaddon::Get().GetAudio().SetVolumeSSRC(*ssrc, volume); - } } [[nodiscard]] float DiscordVoiceClient::GetUserVolume(Snowflake id) const { @@ -345,7 +341,7 @@ void DiscordVoiceClient::HandleGatewaySpeaking(const VoiceGatewayMessage &m) { // set volume if already set but ssrc just found if (const auto iter = m_user_volumes.find(d.UserID); iter != m_user_volumes.end()) { if (m_ssrc_map.find(d.UserID) == m_ssrc_map.end()) { - Abaddon::Get().GetAudio().SetVolumeSSRC(d.SSRC, iter->second); + Abaddon::Get().GetAudio().GetVoice().GetPlayback().GetClientStore().SetClientVolume(d.SSRC, iter->second); } } @@ -472,7 +468,7 @@ void DiscordVoiceClient::OnUDPData(std::vector data) { if (crypto_secretbox_open_easy(payload, payload, data.size() - 12, nonce.data(), m_secret_key.data())) { // spdlog::get("voice")->trace("UDP payload decryption failure"); } else { - Abaddon::Get().GetAudio().FeedMeOpus(ssrc, { payload, payload + data.size() - 12 - crypto_box_MACBYTES }); + Abaddon::Get().GetAudio().GetVoice().GetPlayback().OnRTPData(ssrc, { payload, payload + data.size() - 12 - crypto_box_MACBYTES }); } } diff --git a/src/windows/voicesettingswindow.cpp b/src/windows/voicesettingswindow.cpp index 90d07172..d5df26b5 100644 --- a/src/windows/voicesettingswindow.cpp +++ b/src/windows/voicesettingswindow.cpp @@ -10,6 +10,9 @@ // clang-format on +using SignalHint = AbaddonClient::Audio::Voice::Opus::OpusEncoder::SignalHint; +using EncodingApplication = AbaddonClient::Audio::Voice::Opus::OpusEncoder::EncodingApplication; + VoiceSettingsWindow::VoiceSettingsWindow() : m_main(Gtk::ORIENTATION_VERTICAL) { get_style_context()->add_class("app-window"); @@ -25,25 +28,27 @@ VoiceSettingsWindow::VoiceSettingsWindow() "Music - Optimize for non-voice signals incl. music\n" "Restricted - Optimize for non-voice, low latency. Not recommended"); - const auto mode = Abaddon::Get().GetAudio().GetEncodingApplication(); - if (mode == OPUS_APPLICATION_VOIP) { + const auto mode = Abaddon::Get().GetAudio().GetVoice().GetCapture().GetEncoder()->value().GetEncodingApplication(); + + if (mode == EncodingApplication::VOIP) { m_encoding_mode.set_active(0); - } else if (mode == OPUS_APPLICATION_AUDIO) { + } else if (mode == EncodingApplication::Audio) { m_encoding_mode.set_active(1); - } else if (mode == OPUS_APPLICATION_RESTRICTED_LOWDELAY) { + } else if (mode == EncodingApplication::LowDelay) { m_encoding_mode.set_active(2); } m_encoding_mode.signal_changed().connect([this]() { const auto mode = m_encoding_mode.get_active_text(); - auto &audio = Abaddon::Get().GetAudio(); + auto &encoder = Abaddon::Get().GetAudio().GetVoice().GetCapture().GetEncoder()->value(); + if (mode == "Voice") { - audio.SetEncodingApplication(OPUS_APPLICATION_VOIP); + encoder.SetEncodingApplication(EncodingApplication::VOIP); } else if (mode == "Music") { spdlog::get("audio")->debug("music/audio"); - audio.SetEncodingApplication(OPUS_APPLICATION_AUDIO); + encoder.SetEncodingApplication(EncodingApplication::Audio); } else if (mode == "Restricted") { - audio.SetEncodingApplication(OPUS_APPLICATION_RESTRICTED_LOWDELAY); + encoder.SetEncodingApplication(EncodingApplication::LowDelay); } }); @@ -56,24 +61,28 @@ VoiceSettingsWindow::VoiceSettingsWindow() "Voice - Tell Opus it's a voice signal\n" "Music - Tell Opus it's a music signal"); - const auto signal = Abaddon::Get().GetAudio().GetSignalHint(); - if (signal == OPUS_AUTO) { + const auto signal = Abaddon::Get().GetAudio().GetVoice().GetCapture().GetEncoder()->value().GetSignalHint(); + if (signal == SignalHint::Auto) { m_signal.set_active(0); - } else if (signal == OPUS_SIGNAL_VOICE) { + } else if (signal == SignalHint::Voice) { m_signal.set_active(1); - } else if (signal == OPUS_SIGNAL_MUSIC) { + } else if (signal == SignalHint::Music) { m_signal.set_active(2); } m_signal.signal_changed().connect([this]() { const auto signal = m_signal.get_active_text(); - auto &audio = Abaddon::Get().GetAudio(); + auto encoder = Abaddon::Get().GetAudio().GetVoice().GetCapture().GetEncoder(); + if (!encoder->has_value()) { + return; + } + if (signal == "Auto") { - audio.SetSignalHint(OPUS_AUTO); + encoder->value().SetSignalHint(SignalHint::Auto); } else if (signal == "Voice") { - audio.SetSignalHint(OPUS_SIGNAL_VOICE); + encoder->value().SetSignalHint(SignalHint::Voice); } else if (signal == "Music") { - audio.SetSignalHint(OPUS_SIGNAL_MUSIC); + encoder->value().SetSignalHint(SignalHint::Music); } }); @@ -91,7 +100,7 @@ VoiceSettingsWindow::VoiceSettingsWindow() m_bitrate.set_range(0.0, 100.0); m_bitrate.set_value_pos(Gtk::POS_TOP); - m_bitrate.set_value(bitrate_scale_r(Abaddon::Get().GetAudio().GetBitrate())); + m_bitrate.set_value(bitrate_scale_r(Abaddon::Get().GetAudio().GetVoice().GetCapture().GetEncoder()->value().GetBitrate())); m_bitrate.signal_format_value().connect([this, bitrate_scale](double value) { const auto scaled = bitrate_scale(value); if (value <= 99.9) { @@ -103,16 +112,18 @@ VoiceSettingsWindow::VoiceSettingsWindow() m_bitrate.signal_value_changed().connect([this, bitrate_scale]() { const auto value = m_bitrate.get_value(); const auto scaled = bitrate_scale(value); + + auto& encoder = Abaddon::Get().GetAudio().GetVoice().GetCapture().GetEncoder()->value(); if (value <= 99.9) { - Abaddon::Get().GetAudio().SetBitrate(static_cast(scaled)); + encoder.SetBitrate(static_cast(scaled)); } else { - Abaddon::Get().GetAudio().SetBitrate(OPUS_BITRATE_MAX); + encoder.SetBitrate(OPUS_BITRATE_MAX); } }); m_gain.set_increments(1.0, 5.0); m_gain.set_range(0.0, 6969696969.0); - m_gain.set_value(Abaddon::Get().GetAudio().GetCaptureGain() * 100.0); + m_gain.set_value(Abaddon::Get().GetAudio().GetVoice().GetCapture().Gain * 100.0); const auto cb = [this]() { spdlog::get("ui")->warn("emit"); m_signal_gain.emit(m_gain.get_value() / 100.0); diff --git a/src/windows/voicewindow.cpp b/src/windows/voicewindow.cpp index b0b4f731..e6c4e6f4 100644 --- a/src/windows/voicewindow.cpp +++ b/src/windows/voicewindow.cpp @@ -123,29 +123,18 @@ VoiceWindow::VoiceWindow(Snowflake channel_id) m_vad_param.set_range(0.0, 100.0); m_vad_param.set_value_pos(Gtk::POS_LEFT); m_vad_param.signal_value_changed().connect([this]() { - auto &audio = Abaddon::Get().GetAudio(); const double val = m_vad_param.get_value() * 0.01; - switch (audio.GetVADMethod()) { - case AudioManager::VADMethod::Gate: - audio.SetCaptureGate(val); - m_vad_value.SetTick(val); - break; -#ifdef WITH_RNNOISE - case AudioManager::VADMethod::RNNoise: - audio.SetRNNProbThreshold(val); - m_vad_value.SetTick(val); - break; -#endif - }; + Abaddon::Get().GetAudio().GetVoice().GetCapture() + .GetEffects().SetCurrentThreshold(val); }); UpdateVADParamValue(); m_capture_gain.set_range(0.0, 200.0); m_capture_gain.set_value_pos(Gtk::POS_LEFT); - m_capture_gain.set_value(audio.GetCaptureGain() * 100.0); + m_capture_gain.set_value(audio.GetVoice().GetCapture().Gain * 100.0); m_capture_gain.signal_value_changed().connect([this]() { const double val = m_capture_gain.get_value() / 100.0; - Abaddon::Get().GetAudio().SetCaptureGain(val); + Abaddon::Get().GetAudio().GetVoice().GetCapture().Gain = val; }); m_vad_combo.set_valign(Gtk::ALIGN_END); @@ -167,22 +156,22 @@ VoiceWindow::VoiceWindow(Snowflake channel_id) #endif } m_vad_combo.signal_changed().connect([this]() { - auto &audio = Abaddon::Get().GetAudio(); const auto id = m_vad_combo.get_active_id(); + auto &abaddon = Abaddon::Get(); - audio.SetVADMethod(id); - Abaddon::Get().GetSettings().VAD = id; + abaddon.GetAudio().GetVoice().GetCapture().GetEffects().SetVADMethod(id); + abaddon.GetSettings().VAD = id; UpdateVADParamValue(); }); - m_noise_suppression.set_active(audio.GetSuppressNoise()); + m_noise_suppression.set_active(audio.GetVoice().GetCapture().SuppressNoise); m_noise_suppression.signal_toggled().connect([this]() { - Abaddon::Get().GetAudio().SetSuppressNoise(m_noise_suppression.get_active()); + Abaddon::Get().GetAudio().GetVoice().GetCapture().SuppressNoise = m_noise_suppression.get_active(); }); - m_mix_mono.set_active(audio.GetMixMono()); + m_mix_mono.set_active(audio.GetVoice().GetCapture().MixMono); m_mix_mono.signal_toggled().connect([this]() { - Abaddon::Get().GetAudio().SetMixMono(m_mix_mono.get_active()); + Abaddon::Get().GetAudio().GetVoice().GetCapture().MixMono = m_mix_mono.get_active(); }); auto *playback_renderer = Gtk::make_managed(); @@ -196,7 +185,7 @@ VoiceWindow::VoiceWindow(Snowflake channel_id) m_playback_combo.pack_start(*playback_renderer); m_playback_combo.add_attribute(*playback_renderer, "text", 0); m_playback_combo.signal_changed().connect([this]() { - Abaddon::Get().GetAudio().SetPlaybackDevice(m_playback_combo.get_active()); + m_signal_playback_device_changed.emit(m_playback_combo.get_active()); }); auto *capture_renderer = Gtk::make_managed(); @@ -210,7 +199,7 @@ VoiceWindow::VoiceWindow(Snowflake channel_id) m_capture_combo.pack_start(*capture_renderer); m_capture_combo.add_attribute(*capture_renderer, "text", 0); m_capture_combo.signal_changed().connect([this]() { - Abaddon::Get().GetAudio().SetCaptureDevice(m_capture_combo.get_active()); + m_signal_capture_device_changed.emit(m_capture_combo.get_active()); }); m_menu_bar.append(m_menu_view); @@ -220,7 +209,7 @@ VoiceWindow::VoiceWindow(Snowflake channel_id) auto *window = new VoiceSettingsWindow; const auto cb = [this](double gain) { m_capture_gain.set_value(gain * 100.0); - Abaddon::Get().GetAudio().SetCaptureGain(gain); + Abaddon::Get().GetAudio().GetVoice().GetCapture().Gain = gain; }; window->signal_gain().connect(sigc::track_obj(cb, *this)); window->show(); @@ -298,14 +287,20 @@ void VoiceWindow::OnDeafenChanged() { } bool VoiceWindow::UpdateVoiceMeters() { - auto &audio = Abaddon::Get().GetAudio(); - switch (audio.GetVADMethod()) { - case AudioManager::VADMethod::Gate: - m_vad_value.SetVolume(audio.GetCaptureVolumeLevel()); + using VADMethod = AbaddonClient::Audio::Voice::Capture::VADMethod; + + auto& voice = Abaddon::Get().GetAudio().GetVoice(); + auto& playback = voice.GetPlayback(); + auto& capture = voice.GetCapture(); + auto& effects = capture.GetEffects(); + + switch (effects.GetVADMethod()) { + case VADMethod::Gate: + m_vad_value.SetVolume(capture.GetPeakMeter().GetPeak()); break; #ifdef WITH_RNNOISE - case AudioManager::VADMethod::RNNoise: - m_vad_value.SetVolume(audio.GetCurrentVADProbability()); + case VADMethod::RNNoise: + m_vad_value.SetVolume(effects.GetNoise().GetPeakMeter().GetPeak()); break; #endif } @@ -313,24 +308,15 @@ bool VoiceWindow::UpdateVoiceMeters() { for (auto [id, row] : m_rows) { const auto ssrc = Abaddon::Get().GetDiscordClient().GetSSRCOfUser(id); if (ssrc.has_value()) { - row->SetVolumeMeter(audio.GetSSRCVolumeLevel(*ssrc)); + row->SetVolumeMeter(playback.GetClientStore().GetClientPeakVolume(*ssrc)); } } return true; } void VoiceWindow::UpdateVADParamValue() { - auto &audio = Abaddon::Get().GetAudio(); - switch (audio.GetVADMethod()) { - case AudioManager::VADMethod::Gate: - m_vad_param.set_value(audio.GetCaptureGate() * 100.0); - break; -#ifdef WITH_RNNOISE - case AudioManager::VADMethod::RNNoise: - m_vad_param.set_value(audio.GetRNNProbThreshold() * 100.0); - break; -#endif - } + auto &effects = Abaddon::Get().GetAudio().GetVoice().GetCapture().GetEffects(); + m_vad_param.set_value(effects.GetCurrentThreshold() * 100.0); } void VoiceWindow::OnUserConnect(Snowflake user_id, Snowflake to_channel_id) { @@ -365,4 +351,12 @@ VoiceWindow::type_signal_mute_user_cs VoiceWindow::signal_mute_user_cs() { VoiceWindow::type_signal_user_volume_changed VoiceWindow::signal_user_volume_changed() { return m_signal_user_volume_changed; } + +VoiceWindow::type_signal_playback_device_changed VoiceWindow::signal_playback_device_changed() { + return m_signal_playback_device_changed; +} + +VoiceWindow::type_signal_capture_device_changed VoiceWindow::signal_capture_device_changed() { + return m_signal_capture_device_changed; +} #endif diff --git a/src/windows/voicewindow.hpp b/src/windows/voicewindow.hpp index 018934b2..fde88a4d 100644 --- a/src/windows/voicewindow.hpp +++ b/src/windows/voicewindow.hpp @@ -75,15 +75,24 @@ class VoiceWindow : public Gtk::Window { using type_signal_mute_user_cs = sigc::signal; using type_signal_user_volume_changed = sigc::signal; + using type_signal_playback_device_changed = sigc::signal; + using type_signal_capture_device_changed = sigc::signal; + type_signal_mute signal_mute(); type_signal_deafen signal_deafen(); type_signal_mute_user_cs signal_mute_user_cs(); type_signal_user_volume_changed signal_user_volume_changed(); + type_signal_playback_device_changed signal_playback_device_changed(); + type_signal_capture_device_changed signal_capture_device_changed(); + private: type_signal_mute m_signal_mute; type_signal_deafen m_signal_deafen; type_signal_mute_user_cs m_signal_mute_user_cs; type_signal_user_volume_changed m_signal_user_volume_changed; + + type_signal_playback_device_changed m_signal_playback_device_changed; + type_signal_capture_device_changed m_signal_capture_device_changed; }; #endif From 486fec9ec3937669eabcaa45957611619f984761 Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Sun, 2 Jun 2024 15:32:48 +0300 Subject: [PATCH 19/39] Make PeakMeter decay itself --- src/audio/manager.cpp | 9 --------- src/audio/manager.hpp | 2 -- src/audio/voice/peak_meter/peak_meter.cpp | 12 ++++++++---- src/audio/voice/peak_meter/peak_meter.hpp | 8 +++++++- src/audio/voice/playback/client_store.cpp | 8 -------- src/audio/voice/playback/client_store.hpp | 1 - 6 files changed, 15 insertions(+), 25 deletions(-) diff --git a/src/audio/manager.cpp b/src/audio/manager.cpp index b0a28cdc..d49fe417 100644 --- a/src/audio/manager.cpp +++ b/src/audio/manager.cpp @@ -65,8 +65,6 @@ AudioManager::AudioManager(const Glib::ustring &backends_string, DiscordClient & #endif } - Glib::signal_timeout().connect(sigc::mem_fun(*this, &AudioManager::DecayVolumeMeters), 40); - m_ok = true; } @@ -91,13 +89,6 @@ void AudioManager::Enumerate() { ); } -bool AudioManager::DecayVolumeMeters() { - m_voice->GetCapture().GetPeakMeter().Decay(); - m_voice->GetPlayback().GetClientStore().DecayPeakMeters(); - - return true; -} - bool AudioManager::OK() const { return m_ok; } diff --git a/src/audio/manager.hpp b/src/audio/manager.hpp index fb62aec6..727ea637 100644 --- a/src/audio/manager.hpp +++ b/src/audio/manager.hpp @@ -35,8 +35,6 @@ class AudioManager { void Enumerate(); static std::vector ParseBackendsList(const Glib::ustring &list); - bool DecayVolumeMeters(); - AudioDevices m_devices; std::optional m_context; diff --git a/src/audio/voice/peak_meter/peak_meter.cpp b/src/audio/voice/peak_meter/peak_meter.cpp index af436325..722aaf71 100644 --- a/src/audio/voice/peak_meter/peak_meter.cpp +++ b/src/audio/voice/peak_meter/peak_meter.cpp @@ -2,6 +2,10 @@ namespace AbaddonClient::Audio::Voice { +PeakMeter::PeakMeter() noexcept { + Glib::signal_timeout().connect(sigc::mem_fun(*this, &PeakMeter::Decay), 40); +} + void PeakMeter::UpdatePeak(InputBuffer buffer) noexcept { // Cache to prevent atomic operations in the loop float peak = m_peak; @@ -13,10 +17,6 @@ void PeakMeter::UpdatePeak(InputBuffer buffer) noexcept { m_peak = peak; } -void PeakMeter::Decay() noexcept { - m_peak = std::max(m_peak - 0.05f, 0.0f); -} - void PeakMeter::SetPeak(float peak) noexcept { m_peak = std::max(m_peak.load(), peak); } @@ -25,5 +25,9 @@ float PeakMeter::GetPeak() const noexcept { return m_peak; } +bool PeakMeter::Decay() noexcept { + m_peak = std::max(m_peak - 0.05f, 0.0f); + return true; +} } diff --git a/src/audio/voice/peak_meter/peak_meter.hpp b/src/audio/voice/peak_meter/peak_meter.hpp index adc3cdbc..c3677bcb 100644 --- a/src/audio/voice/peak_meter/peak_meter.hpp +++ b/src/audio/voice/peak_meter/peak_meter.hpp @@ -1,17 +1,23 @@ #pragma once +#include + #include "audio/utils.hpp" + namespace AbaddonClient::Audio::Voice { class PeakMeter { public: + PeakMeter() noexcept; + void UpdatePeak(InputBuffer buffer) noexcept; - void Decay() noexcept; void SetPeak(float peak) noexcept; float GetPeak() const noexcept; private: + bool Decay() noexcept; + std::atomic m_peak = 0; }; diff --git a/src/audio/voice/playback/client_store.cpp b/src/audio/voice/playback/client_store.cpp index a4053f5b..4382b282 100644 --- a/src/audio/voice/playback/client_store.cpp +++ b/src/audio/voice/playback/client_store.cpp @@ -79,14 +79,6 @@ void ClientStore::WriteMixed(OutputBuffer buffer) noexcept { } } -void ClientStore::DecayPeakMeters() noexcept { - auto clients = m_clients.Lock(); - - for (auto& [_, client] : clients) { - client.GetPeakMeter().Decay(); - } -} - void ClientStore::SetClientVolume(ClientID id, float volume) noexcept { auto clients = m_clients.Lock(); auto client = clients->find(id); diff --git a/src/audio/voice/playback/client_store.hpp b/src/audio/voice/playback/client_store.hpp index ebaceead..e84d0971 100644 --- a/src/audio/voice/playback/client_store.hpp +++ b/src/audio/voice/playback/client_store.hpp @@ -21,7 +21,6 @@ class ClientStore { void RemoveClient(ClientID id) noexcept; void Clear() noexcept; - void DecayPeakMeters() noexcept; void ClearAllBuffers() noexcept; void SetClientVolume(ClientID id, float volume) noexcept; From 3d89d24a43016f3d311bb5e57361050c16d109a4 Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Sun, 2 Jun 2024 20:14:15 +0300 Subject: [PATCH 20/39] Wrap MaEngine --- src/audio/miniaudio/ma_engine.cpp | 54 +++++++++++++++++++++++++++++++ src/audio/miniaudio/ma_engine.hpp | 34 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 src/audio/miniaudio/ma_engine.cpp create mode 100644 src/audio/miniaudio/ma_engine.hpp diff --git a/src/audio/miniaudio/ma_engine.cpp b/src/audio/miniaudio/ma_engine.cpp new file mode 100644 index 00000000..a0cc1c80 --- /dev/null +++ b/src/audio/miniaudio/ma_engine.cpp @@ -0,0 +1,54 @@ +#include "ma_engine.hpp" + +namespace AbaddonClient::Audio::Miniaudio { + +MaEngine::MaEngine(EnginePtr &&engine) noexcept : + m_engine(std::move(engine)) {} + +std::optional MaEngine::Create(ma_engine_config &&config) noexcept { + EnginePtr engine = EnginePtr(new ma_engine); + + const auto result = ma_engine_init(&config, engine.get()); + if (result != MA_SUCCESS) { + spdlog::get("audio")->error("Failed to create engine: {}", ma_result_description(result)); + return std::nullopt; + } + + return MaEngine(std::move(engine)); +} + +bool MaEngine::Start() noexcept { + const auto result = ma_engine_start(m_engine.get()); + if (result != MA_SUCCESS) { + spdlog::get("audio")->error("Failed to start engine: {}", ma_result_description(result)); + return false; + } + + return true; +} + +bool MaEngine::Stop() noexcept { + const auto result = ma_engine_stop(m_engine.get()); + if (result != MA_SUCCESS) { + spdlog::get("audio")->error("Failed to stop engine: {}", ma_result_description(result)); + return false; + } + + return true; +} + +bool MaEngine::PlaySound(std::string_view file_path) noexcept { + const auto result = ma_engine_play_sound(m_engine.get(), file_path.data(), nullptr); + if (result != MA_SUCCESS) { + spdlog::get("audio")->error("Failed to play sound at {}: {}", file_path.data(), ma_result_description(result)); + return false; + } + + return true; +} + +ma_engine& MaEngine::GetInternal() noexcept { + return *m_engine; +} + +} diff --git a/src/audio/miniaudio/ma_engine.hpp b/src/audio/miniaudio/ma_engine.hpp new file mode 100644 index 00000000..85d128c7 --- /dev/null +++ b/src/audio/miniaudio/ma_engine.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include + +namespace AbaddonClient::Audio::Miniaudio { + +class MaEngine { +public: + static std::optional Create(ma_engine_config &&config) noexcept; + + bool Start() noexcept; + bool Stop() noexcept; + + bool PlaySound(std::string_view file_path) noexcept; + + ma_engine& GetInternal() noexcept; + +private: + struct EngineDeleter { + void operator()(ma_engine* ptr) noexcept { + ma_engine_uninit(ptr); + } + }; + + // Put ma_engine behind pointer to allow moving. + // miniaudio expects ma_engine reference to be valid at all times + // Moving it to other location would cause memory corruption + using EnginePtr = std::unique_ptr; + MaEngine(EnginePtr &&engine) noexcept; + + EnginePtr m_engine; +}; + +} From 2a1150a65c83be1ff52163c8656c8f712d847b33 Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Sun, 2 Jun 2024 20:14:50 +0300 Subject: [PATCH 21/39] Implement SystemAudio --- src/audio/manager.cpp | 22 ++++++++++++++++++---- src/audio/manager.hpp | 12 ++++++++++-- src/audio/system/system_audio.cpp | 25 +++++++++++++++++++++++++ src/audio/system/system_audio.hpp | 22 ++++++++++++++++++++++ src/notifications/notifier.hpp | 12 ------------ src/notifications/notifier_fallback.cpp | 21 +++++---------------- src/notifications/notifier_gio.cpp | 21 +++++---------------- 7 files changed, 85 insertions(+), 50 deletions(-) create mode 100644 src/audio/system/system_audio.cpp create mode 100644 src/audio/system/system_audio.hpp diff --git a/src/audio/manager.cpp b/src/audio/manager.cpp index d49fe417..38281f2b 100644 --- a/src/audio/manager.cpp +++ b/src/audio/manager.cpp @@ -57,13 +57,20 @@ AudioManager::AudioManager(const Glib::ustring &backends_string, DiscordClient & m_context = AbaddonClient::Audio::Context::Create(std::move(ctx_cfg), backends); - if (m_context) { - Enumerate(); + if (!m_context) { + return; + } + + Enumerate(); + + auto engine = AbaddonClient::Audio::Miniaudio::MaEngine::Create(ma_engine_config_init()); + if (engine) { + m_system.emplace(std::move(*engine)); + } #if WITH_VOICE - m_voice.emplace(*m_context, discord); + m_voice.emplace(*m_context, discord); #endif - } m_ok = true; } @@ -131,5 +138,12 @@ const AbaddonClient::Audio::VoiceAudio& AudioManager::GetVoice() const noexcept #endif +AbaddonClient::Audio::SystemAudio& AudioManager::GetSystem() noexcept { + return *m_system; +} + +const AbaddonClient::Audio::SystemAudio& AudioManager::GetSystem() const noexcept { + return *m_system; +} #endif diff --git a/src/audio/manager.hpp b/src/audio/manager.hpp index 727ea637..5aa4c376 100644 --- a/src/audio/manager.hpp +++ b/src/audio/manager.hpp @@ -15,6 +15,8 @@ #include "voice/voice_audio.hpp" #endif +#include "system/system_audio.hpp" + #include "miniaudio/ma_log.hpp" // clang-format on @@ -23,13 +25,17 @@ class AudioManager { public: AudioManager(const Glib::ustring &backends_string, DiscordClient &discord); + AudioDevices &GetDevices(); + + AbaddonClient::Audio::SystemAudio& GetSystem() noexcept; + const AbaddonClient::Audio::SystemAudio& GetSystem() const noexcept; + #if WITH_VOICE AbaddonClient::Audio::VoiceAudio& GetVoice() noexcept; const AbaddonClient::Audio::VoiceAudio& GetVoice() const noexcept; #endif - bool OK() const; - AudioDevices &GetDevices(); + bool OK() const; private: void Enumerate(); @@ -46,6 +52,8 @@ class AudioManager { std::optional m_voice; #endif + std::optional m_system; + bool m_ok = false; }; #endif diff --git a/src/audio/system/system_audio.cpp b/src/audio/system/system_audio.cpp new file mode 100644 index 00000000..fecac672 --- /dev/null +++ b/src/audio/system/system_audio.cpp @@ -0,0 +1,25 @@ +#include "system_audio.hpp" + +namespace AbaddonClient::Audio { + +SystemAudio::SystemAudio(Miniaudio::MaEngine &&engine) noexcept : + m_engine(std::move(engine)) {} + +void SystemAudio::PlaySound(SystemSound sound) noexcept { + const auto path = [&]() { + switch (sound) { +#ifdef ENABLE_NOTIFICATION_SOUNDS + case SystemSound::NOTIFICATION_SOUND: { + return "/sound/message.mp3"; + } +#endif + } + + return ""; + }(); + + const auto full_path = Abaddon::Get().GetResPath() + path; + m_engine.PlaySound(full_path); +} + +} diff --git a/src/audio/system/system_audio.hpp b/src/audio/system/system_audio.hpp new file mode 100644 index 00000000..5fd36113 --- /dev/null +++ b/src/audio/system/system_audio.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "audio/miniaudio/ma_engine.hpp" + +namespace AbaddonClient::Audio { + +class SystemAudio { +public: + enum SystemSound { +#ifdef ENABLE_NOTIFICATION_SOUNDS + NOTIFICATION_SOUND +#endif + }; + + SystemAudio(Miniaudio::MaEngine &&engine) noexcept; + + void PlaySound(SystemSound sound) noexcept; +private: + Miniaudio::MaEngine m_engine; +}; + +} diff --git a/src/notifications/notifier.hpp b/src/notifications/notifier.hpp index cd3eb71d..f0a20306 100644 --- a/src/notifications/notifier.hpp +++ b/src/notifications/notifier.hpp @@ -2,20 +2,8 @@ #include #include -#ifdef ENABLE_NOTIFICATION_SOUNDS - #include -#endif - class Notifier { public: - Notifier(); - ~Notifier(); - void Notify(const Glib::ustring &id, const Glib::ustring &title, const Glib::ustring &text, const Glib::ustring &default_action, const std::string &icon_path); void Withdraw(const Glib::ustring &id); - -private: -#ifdef ENABLE_NOTIFICATION_SOUNDS - ma_engine m_engine; -#endif }; diff --git a/src/notifications/notifier_fallback.cpp b/src/notifications/notifier_fallback.cpp index 8478fc7d..33455e1e 100644 --- a/src/notifications/notifier_fallback.cpp +++ b/src/notifications/notifier_fallback.cpp @@ -7,24 +7,13 @@ maybe it can be LoadLibrary'd in :s */ -Notifier::Notifier() { -#ifdef ENABLE_NOTIFICATION_SOUNDS - if (ma_engine_init(nullptr, &m_engine) != MA_SUCCESS) { - printf("failed to initialize miniaudio engine\n"); - } -#endif -} - -Notifier::~Notifier() { -#ifdef ENABLE_NOTIFICATION_SOUNDS - ma_engine_uninit(&m_engine); -#endif -} - void Notifier::Notify(const Glib::ustring &id, const Glib::ustring &title, const Glib::ustring &text, const Glib::ustring &default_action, const std::string &icon_path) { #ifdef ENABLE_NOTIFICATION_SOUNDS - if (Abaddon::Get().GetSettings().NotificationsPlaySound) { - ma_engine_play_sound(&m_engine, Abaddon::Get().GetResPath("/sound/message.mp3").c_str(), nullptr); + using SystemSound = AbaddonClient::Audio::SystemAudio::SystemSound; + + auto& abaddon = Abaddon::Get(); + if (abaddon.GetSettings().NotificationsPlaySound) { + abaddon.GetAudio().GetSystem().PlaySound(SystemSound::NOTIFICATION_SOUND); } #endif } diff --git a/src/notifications/notifier_gio.cpp b/src/notifications/notifier_gio.cpp index 2708407e..6f23871e 100644 --- a/src/notifications/notifier_gio.cpp +++ b/src/notifications/notifier_gio.cpp @@ -4,20 +4,6 @@ #include "abaddon.hpp" -Notifier::Notifier() { -#ifdef ENABLE_NOTIFICATION_SOUNDS - if (ma_engine_init(nullptr, &m_engine) != MA_SUCCESS) { - printf("failed to initialize miniaudio engine\n"); - } -#endif -} - -Notifier::~Notifier() { -#ifdef ENABLE_NOTIFICATION_SOUNDS - ma_engine_uninit(&m_engine); -#endif -} - void Notifier::Notify(const Glib::ustring &id, const Glib::ustring &title, const Glib::ustring &text, const Glib::ustring &default_action, const std::string &icon_path) { auto n = Gio::Notification::create(title); n->set_body(text); @@ -35,8 +21,11 @@ void Notifier::Notify(const Glib::ustring &id, const Glib::ustring &title, const g_object_unref(file); #ifdef ENABLE_NOTIFICATION_SOUNDS - if (Abaddon::Get().GetSettings().NotificationsPlaySound) { - ma_engine_play_sound(&m_engine, Abaddon::Get().GetResPath("/sound/message.mp3").c_str(), nullptr); + using SystemSound = AbaddonClient::Audio::SystemAudio::SystemSound; + + auto& abaddon = Abaddon::Get(); + if (abaddon.GetSettings().NotificationsPlaySound) { + abaddon.GetAudio().GetSystem().PlaySound(SystemSound::NOTIFICATION_SOUND); } #endif } From 5e0b8f6d9daef947fe3627ca4d0fdc36ec792f74 Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Mon, 3 Jun 2024 02:09:22 +0300 Subject: [PATCH 22/39] Make notification device start on demand --- src/audio/audio_engine.cpp | 50 +++++++++++++++++++++++++++++++ src/audio/audio_engine.hpp | 29 ++++++++++++++++++ src/audio/context.cpp | 6 ++++ src/audio/context.hpp | 2 ++ src/audio/manager.cpp | 5 +--- src/audio/system/system_audio.cpp | 4 +-- src/audio/system/system_audio.hpp | 7 +++-- 7 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 src/audio/audio_engine.cpp create mode 100644 src/audio/audio_engine.hpp diff --git a/src/audio/audio_engine.cpp b/src/audio/audio_engine.cpp new file mode 100644 index 00000000..1597b66f --- /dev/null +++ b/src/audio/audio_engine.cpp @@ -0,0 +1,50 @@ +#include "audio_engine.hpp" + +namespace AbaddonClient::Audio { + +AudioEngine::AudioEngine(Context &context) noexcept : + m_context(context) {} + +AudioEngine::~AudioEngine() noexcept { + StopTimer(); +} + +bool AudioEngine::PlaySound(std::string_view file_path) noexcept { + StopTimer(); + + const auto result = m_engine.LockScope([this, &file_path](std::optional &engine) { + if (!engine) { + engine = Miniaudio::MaEngine::Create(m_context.GetEngineConfig()); + if (!engine) { + return false; + } + } + + return engine->PlaySound(file_path); + }); + + StartTimer(); + return result; +} + +void AudioEngine::StartTimer() noexcept { + // NOTE: I am not using g_timeout_add_seconds_once here since we want to own the source and destroy it manually + // g_source_remove throws an error on destroyed source + m_timer_source = g_timeout_source_new_seconds(5); + + g_source_set_callback(m_timer_source, GSourceFunc(AudioEngine::TimeoutEngine), this, nullptr); + g_source_attach(m_timer_source, Glib::MainContext::get_default()->gobj()); +} + +void AudioEngine::StopTimer() noexcept { + if (m_timer_source != nullptr) { + g_source_destroy(m_timer_source); + g_source_unref(m_timer_source); + } +} + +void AudioEngine::TimeoutEngine(AudioEngine &engine) noexcept { + engine.m_engine.Lock()->reset(); +} + +} diff --git a/src/audio/audio_engine.hpp b/src/audio/audio_engine.hpp new file mode 100644 index 00000000..09d06b5e --- /dev/null +++ b/src/audio/audio_engine.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include + +#include "misc/mutex.hpp" + +#include "miniaudio/ma_engine.hpp" + +namespace AbaddonClient::Audio { + +class AudioEngine { +public: + AudioEngine(Context& context) noexcept; + ~AudioEngine() noexcept; + + bool PlaySound(std::string_view file_path) noexcept; +private: + void StartTimer() noexcept; + void StopTimer() noexcept; + + static void TimeoutEngine(AudioEngine &engine) noexcept; + + Context &m_context; + Mutex> m_engine; + + GSource* m_timer_source; +}; + +} diff --git a/src/audio/context.cpp b/src/audio/context.cpp index 39c46290..b77a6bfe 100644 --- a/src/audio/context.cpp +++ b/src/audio/context.cpp @@ -18,6 +18,12 @@ std::optional Context::Create(ma_context_config &&config, ConstSliceGetInternal(); + + return config; +} ConstSlice Context::GetPlaybackDevices() noexcept { return m_playback_devices; diff --git a/src/audio/context.hpp b/src/audio/context.hpp index 456e03d2..dd4d3a1f 100644 --- a/src/audio/context.hpp +++ b/src/audio/context.hpp @@ -9,6 +9,8 @@ class Context { Context(Miniaudio::MaContext &&context) noexcept; static std::optional Create(ma_context_config &&config, ConstSlice backends) noexcept; + ma_engine_config GetEngineConfig() noexcept; + ConstSlice GetPlaybackDevices() noexcept; ConstSlice GetCaptureDevices() noexcept; diff --git a/src/audio/manager.cpp b/src/audio/manager.cpp index 38281f2b..18d99184 100644 --- a/src/audio/manager.cpp +++ b/src/audio/manager.cpp @@ -63,10 +63,7 @@ AudioManager::AudioManager(const Glib::ustring &backends_string, DiscordClient & Enumerate(); - auto engine = AbaddonClient::Audio::Miniaudio::MaEngine::Create(ma_engine_config_init()); - if (engine) { - m_system.emplace(std::move(*engine)); - } + m_system.emplace(*m_context); #if WITH_VOICE m_voice.emplace(*m_context, discord); diff --git a/src/audio/system/system_audio.cpp b/src/audio/system/system_audio.cpp index fecac672..ec3d27c9 100644 --- a/src/audio/system/system_audio.cpp +++ b/src/audio/system/system_audio.cpp @@ -2,8 +2,8 @@ namespace AbaddonClient::Audio { -SystemAudio::SystemAudio(Miniaudio::MaEngine &&engine) noexcept : - m_engine(std::move(engine)) {} +SystemAudio::SystemAudio(Context &context) noexcept : + m_engine(context) {} void SystemAudio::PlaySound(SystemSound sound) noexcept { const auto path = [&]() { diff --git a/src/audio/system/system_audio.hpp b/src/audio/system/system_audio.hpp index 5fd36113..1f2e5596 100644 --- a/src/audio/system/system_audio.hpp +++ b/src/audio/system/system_audio.hpp @@ -1,6 +1,6 @@ #pragma once -#include "audio/miniaudio/ma_engine.hpp" +#include "audio/audio_engine.hpp" namespace AbaddonClient::Audio { @@ -12,11 +12,12 @@ class SystemAudio { #endif }; - SystemAudio(Miniaudio::MaEngine &&engine) noexcept; + SystemAudio(Context &context) noexcept; void PlaySound(SystemSound sound) noexcept; + private: - Miniaudio::MaEngine m_engine; + AudioEngine m_engine; }; } From cb44e057ef930d667abb6c31b8b03b104d74dd5d Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Mon, 3 Jun 2024 03:14:53 +0300 Subject: [PATCH 23/39] More voice sounds --- src/abaddon.cpp | 8 ++++ src/audio/manager.cpp | 1 + src/audio/system/system_audio.cpp | 61 ++++++++++++++++++++++++++----- src/audio/system/system_audio.hpp | 24 +++++++++++- 4 files changed, 82 insertions(+), 12 deletions(-) diff --git a/src/abaddon.cpp b/src/abaddon.cpp index dade8af1..01073353 100644 --- a/src/abaddon.cpp +++ b/src/abaddon.cpp @@ -493,6 +493,8 @@ void Abaddon::OnVoiceDisconnected() { } void Abaddon::ShowVoiceWindow() { + using SystemSound = AbaddonClient::Audio::SystemAudio::SystemSound; + if (m_voice_window != nullptr) return; auto *wnd = new VoiceWindow(m_discord.GetVoiceChannelID()); @@ -501,11 +503,17 @@ void Abaddon::ShowVoiceWindow() { wnd->signal_mute().connect([this](bool is_mute) { m_discord.SetVoiceMuted(is_mute); m_audio.GetVoice().GetCapture().SetActive(!is_mute); + + auto sound = is_mute ? SystemSound::VOICE_MUTED : SystemSound::VOICE_UNMUTED; + m_audio.GetSystem().PlaySound(sound); }); wnd->signal_deafen().connect([this](bool is_deaf) { m_discord.SetVoiceDeafened(is_deaf); m_audio.GetVoice().GetPlayback().SetActive(!is_deaf); + + auto sound = is_deaf ? SystemSound::VOICE_DEAFENED : SystemSound::VOICE_UNDEAFENED; + m_audio.GetSystem().PlaySound(sound); }); wnd->signal_mute_user_cs().connect([this](Snowflake id, bool is_mute) { diff --git a/src/audio/manager.cpp b/src/audio/manager.cpp index 18d99184..b1e8a169 100644 --- a/src/audio/manager.cpp +++ b/src/audio/manager.cpp @@ -67,6 +67,7 @@ AudioManager::AudioManager(const Glib::ustring &backends_string, DiscordClient & #if WITH_VOICE m_voice.emplace(*m_context, discord); + m_system->BindToVoice(discord); #endif m_ok = true; diff --git a/src/audio/system/system_audio.cpp b/src/audio/system/system_audio.cpp index ec3d27c9..6c126763 100644 --- a/src/audio/system/system_audio.cpp +++ b/src/audio/system/system_audio.cpp @@ -5,21 +5,62 @@ namespace AbaddonClient::Audio { SystemAudio::SystemAudio(Context &context) noexcept : m_engine(context) {} -void SystemAudio::PlaySound(SystemSound sound) noexcept { - const auto path = [&]() { - switch (sound) { +std::string_view SystemAudio::GetSoundPath(SystemSound sound) noexcept { + switch (sound) { #ifdef ENABLE_NOTIFICATION_SOUNDS - case SystemSound::NOTIFICATION_SOUND: { - return "/sound/message.mp3"; - } + case SystemSound::NOTIFICATION_SOUND: { + return "/sound/message.mp3"; + } #endif + +#ifdef WITH_VOICE + case SystemSound::VOICE_CONNECTED: { + return "/sound/voice_connected.mp3"; + } + case SystemSound::VOICE_DISCONNECTED: { + return "/sound/voice_disconnected.mp3"; + } + case SystemSound::VOICE_MUTED: { + return "/sound/voice_muted.mp3"; + } + case SystemSound::VOICE_UNMUTED: { + return "/sound/voice_unmuted.mp3"; } + case SystemSound::VOICE_DEAFENED: { + return "/sound/voice_deafened.mp3"; + } + case SystemSound::VOICE_UNDEAFENED: { + return "/sound/voice_undeafened.mp3"; + } +#endif + } - return ""; - }(); + return ""; +} - const auto full_path = Abaddon::Get().GetResPath() + path; - m_engine.PlaySound(full_path); +void SystemAudio::PlaySound(SystemSound sound) noexcept { + const auto path = Abaddon::Get().GetResPath() + GetSoundPath(sound).data(); + m_engine.PlaySound(path); } +#ifdef WITH_VOICE + +void SystemAudio::BindToVoice(DiscordClient &discord) noexcept { + discord.signal_voice_user_connect() + .connect(sigc::mem_fun(*this, &SystemAudio::OnVoiceUserConnect)); + + discord.signal_voice_user_disconnect() + .connect(sigc::mem_fun(*this, &SystemAudio::OnVoiceUserDisconnect)); +} + +void SystemAudio::OnVoiceUserConnect(Snowflake user_id, Snowflake channel_id) noexcept { + PlaySound(SystemSound::VOICE_CONNECTED); +} + +void SystemAudio::OnVoiceUserDisconnect(Snowflake user_id, Snowflake channel_id) noexcept { + PlaySound(SystemSound::VOICE_DISCONNECTED); +} + +#endif + } diff --git a/src/audio/system/system_audio.hpp b/src/audio/system/system_audio.hpp index 1f2e5596..83d9d8ad 100644 --- a/src/audio/system/system_audio.hpp +++ b/src/audio/system/system_audio.hpp @@ -4,19 +4,39 @@ namespace AbaddonClient::Audio { -class SystemAudio { +class SystemAudio : public sigc::trackable { public: enum SystemSound { #ifdef ENABLE_NOTIFICATION_SOUNDS - NOTIFICATION_SOUND + NOTIFICATION_SOUND, +#endif + +#ifdef WITH_VOICE + VOICE_CONNECTED, + VOICE_DISCONNECTED, + VOICE_MUTED, + VOICE_UNMUTED, + VOICE_DEAFENED, + VOICE_UNDEAFENED, #endif }; SystemAudio(Context &context) noexcept; +#ifdef WITH_VOICE + void BindToVoice(DiscordClient &discord) noexcept; +#endif + + std::string_view GetSoundPath(SystemSound sound) noexcept; void PlaySound(SystemSound sound) noexcept; private: + +#ifdef WITH_VOICE + void OnVoiceUserConnect(Snowflake user_id, Snowflake channel_id) noexcept; + void OnVoiceUserDisconnect(Snowflake user_id, Snowflake channel_id) noexcept; +#endif + AudioEngine m_engine; }; From 030f5da0a52543a989d66ae450e31ae0dc7e86fe Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Mon, 3 Jun 2024 03:17:38 +0300 Subject: [PATCH 24/39] Why snake case? --- src/abaddon.cpp | 4 ++-- src/audio/system/system_audio.cpp | 18 +++++++++--------- src/audio/system/system_audio.hpp | 14 +++++++------- src/notifications/notifier_fallback.cpp | 2 +- src/notifications/notifier_gio.cpp | 2 +- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/abaddon.cpp b/src/abaddon.cpp index 01073353..370b310e 100644 --- a/src/abaddon.cpp +++ b/src/abaddon.cpp @@ -504,7 +504,7 @@ void Abaddon::ShowVoiceWindow() { m_discord.SetVoiceMuted(is_mute); m_audio.GetVoice().GetCapture().SetActive(!is_mute); - auto sound = is_mute ? SystemSound::VOICE_MUTED : SystemSound::VOICE_UNMUTED; + auto sound = is_mute ? SystemSound::VoiceMuted : SystemSound::VoiceUnmuted; m_audio.GetSystem().PlaySound(sound); }); @@ -512,7 +512,7 @@ void Abaddon::ShowVoiceWindow() { m_discord.SetVoiceDeafened(is_deaf); m_audio.GetVoice().GetPlayback().SetActive(!is_deaf); - auto sound = is_deaf ? SystemSound::VOICE_DEAFENED : SystemSound::VOICE_UNDEAFENED; + auto sound = is_deaf ? SystemSound::VoiceDeafened : SystemSound::VoiceUndeafened; m_audio.GetSystem().PlaySound(sound); }); diff --git a/src/audio/system/system_audio.cpp b/src/audio/system/system_audio.cpp index 6c126763..8a8a1ab6 100644 --- a/src/audio/system/system_audio.cpp +++ b/src/audio/system/system_audio.cpp @@ -8,28 +8,28 @@ SystemAudio::SystemAudio(Context &context) noexcept : std::string_view SystemAudio::GetSoundPath(SystemSound sound) noexcept { switch (sound) { #ifdef ENABLE_NOTIFICATION_SOUNDS - case SystemSound::NOTIFICATION_SOUND: { + case SystemSound::Notification: { return "/sound/message.mp3"; } #endif #ifdef WITH_VOICE - case SystemSound::VOICE_CONNECTED: { + case SystemSound::VoiceConnected: { return "/sound/voice_connected.mp3"; } - case SystemSound::VOICE_DISCONNECTED: { + case SystemSound::VoiceDisconnected: { return "/sound/voice_disconnected.mp3"; } - case SystemSound::VOICE_MUTED: { + case SystemSound::VoiceMuted: { return "/sound/voice_muted.mp3"; } - case SystemSound::VOICE_UNMUTED: { + case SystemSound::VoiceUnmuted: { return "/sound/voice_unmuted.mp3"; } - case SystemSound::VOICE_DEAFENED: { + case SystemSound::VoiceDeafened: { return "/sound/voice_deafened.mp3"; } - case SystemSound::VOICE_UNDEAFENED: { + case SystemSound::VoiceUndeafened: { return "/sound/voice_undeafened.mp3"; } #endif @@ -54,11 +54,11 @@ void SystemAudio::BindToVoice(DiscordClient &discord) noexcept { } void SystemAudio::OnVoiceUserConnect(Snowflake user_id, Snowflake channel_id) noexcept { - PlaySound(SystemSound::VOICE_CONNECTED); + PlaySound(SystemSound::VoiceConnected); } void SystemAudio::OnVoiceUserDisconnect(Snowflake user_id, Snowflake channel_id) noexcept { - PlaySound(SystemSound::VOICE_DISCONNECTED); + PlaySound(SystemSound::VoiceDisconnected); } #endif diff --git a/src/audio/system/system_audio.hpp b/src/audio/system/system_audio.hpp index 83d9d8ad..58ef34f9 100644 --- a/src/audio/system/system_audio.hpp +++ b/src/audio/system/system_audio.hpp @@ -8,16 +8,16 @@ class SystemAudio : public sigc::trackable { public: enum SystemSound { #ifdef ENABLE_NOTIFICATION_SOUNDS - NOTIFICATION_SOUND, + Notification, #endif #ifdef WITH_VOICE - VOICE_CONNECTED, - VOICE_DISCONNECTED, - VOICE_MUTED, - VOICE_UNMUTED, - VOICE_DEAFENED, - VOICE_UNDEAFENED, + VoiceConnected, + VoiceDisconnected, + VoiceMuted, + VoiceUnmuted, + VoiceDeafened, + VoiceUndeafened, #endif }; diff --git a/src/notifications/notifier_fallback.cpp b/src/notifications/notifier_fallback.cpp index 33455e1e..45a62aa6 100644 --- a/src/notifications/notifier_fallback.cpp +++ b/src/notifications/notifier_fallback.cpp @@ -13,7 +13,7 @@ void Notifier::Notify(const Glib::ustring &id, const Glib::ustring &title, const auto& abaddon = Abaddon::Get(); if (abaddon.GetSettings().NotificationsPlaySound) { - abaddon.GetAudio().GetSystem().PlaySound(SystemSound::NOTIFICATION_SOUND); + abaddon.GetAudio().GetSystem().PlaySound(SystemSound::Notification); } #endif } diff --git a/src/notifications/notifier_gio.cpp b/src/notifications/notifier_gio.cpp index 6f23871e..30ed7d06 100644 --- a/src/notifications/notifier_gio.cpp +++ b/src/notifications/notifier_gio.cpp @@ -25,7 +25,7 @@ void Notifier::Notify(const Glib::ustring &id, const Glib::ustring &title, const auto& abaddon = Abaddon::Get(); if (abaddon.GetSettings().NotificationsPlaySound) { - abaddon.GetAudio().GetSystem().PlaySound(SystemSound::NOTIFICATION_SOUND); + abaddon.GetAudio().GetSystem().PlaySound(SystemSound::Notification); } #endif } From bc4b6d1dda957513bab411da76610afa00deb1d1 Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Mon, 3 Jun 2024 04:17:09 +0300 Subject: [PATCH 25/39] Add voice sounds --- res/res/sound/voice_connected.mp3 | Bin 0 -> 47179 bytes res/res/sound/voice_deafened.mp3 | Bin 0 -> 32551 bytes res/res/sound/voice_disconnected.mp3 | Bin 0 -> 41951 bytes res/res/sound/voice_muted.mp3 | Bin 0 -> 22102 bytes res/res/sound/voice_undeafened.mp3 | Bin 0 -> 35685 bytes res/res/sound/voice_unmuted.mp3 | Bin 0 -> 22102 bytes 6 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 res/res/sound/voice_connected.mp3 create mode 100644 res/res/sound/voice_deafened.mp3 create mode 100644 res/res/sound/voice_disconnected.mp3 create mode 100644 res/res/sound/voice_muted.mp3 create mode 100644 res/res/sound/voice_undeafened.mp3 create mode 100644 res/res/sound/voice_unmuted.mp3 diff --git a/res/res/sound/voice_connected.mp3 b/res/res/sound/voice_connected.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..3e030facef38f7be43415f83db8daf7eca846dbe GIT binary patch literal 47179 zcmeFYWmH?yw)dUjf#4S0-GUW|;I73r1PfZAK%q#3yF10DIK>MT+Tc!c3WXLg+5!a% zZ7C1uJm-Dh=ffR$oN?bf-cNUqvByrb)*jiJS@XB&{I9h%RYWlX|1c_JRXyd0Gx*_9 z^t5+$@_))~;QQqM_om}Nn*TG?^m2ak&`$ht@B;vE>H*l;1O%j{)YQ}r46Ll&+=7CF zVq((LFqo>UsexIIBA03deH zj+d(o0Ky*bgO31-ne>l_|FQq^!2fvQzs~~?Oi%#;a0p=i*-ro%qbG098f5{F8FYNz zzX4d!2lgZsU_gv4j7z-!DQgd~DI7bdX4iX8?I6(p=&NU-KQV?bC!)Zu_LUnajNTy< z1I?w!kkqejY9aO_Q6z?Wa^tstMLLoB%NMJI=@f4X-+#Zmv47Ta(>FGR$jmyA^|c5v zqkB`QvF>H}#Dqsk>&uw$Q@_jGqm1I>&oAz8=Z%ca_x)}clP6D)@wFjOz|cYB`{kg& z&6SlcJvq>Wz(CylO}6{N!ND�Gj?A0N(wStn5FW1C~`lV}AJy0nVP^-xr`u4|N=p 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%0Dem1~$%v+rXj(7bxB@~zg#%$$$ zxTW)$7~5pGy=O0PMN8OR(;tO6iL%L^nuAZ8A}s4*iDkA-@bqvc z87yHhmHLx1vG7Uq;KA1OKBs7H;bB&)=sCgQ>e=>V`;%HzirTgJHiJQ`Augx9F&qb4 z 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 C)RfBr literal 0 HcmV?d00001 diff --git a/res/res/sound/voice_disconnected.mp3 b/res/res/sound/voice_disconnected.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..c58e9ed437d48668e9727d9f5eb3ebd0b3ccbb75 GIT binary patch literal 41951 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 literal 0 HcmV?d00001 diff --git a/res/res/sound/voice_muted.mp3 b/res/res/sound/voice_muted.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..b87de43ce0a17e04c353b93c2ce7a9ee77b90031 GIT binary patch literal 22102 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 c{ER3#SJ3RjYxpCp7#e)W+GPBs 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 literal 0 HcmV?d00001 diff --git a/res/res/sound/voice_unmuted.mp3 b/res/res/sound/voice_unmuted.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..fd784228fa6967719c25a8c5295b344361fffa67 GIT binary patch literal 22102 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 Date: Mon, 3 Jun 2024 21:57:27 +0300 Subject: [PATCH 26/39] Play connect/disconnect sounds only for the current channel --- src/audio/system/system_audio.cpp | 12 ++++++++++++ src/audio/system/system_audio.hpp | 2 ++ 2 files changed, 14 insertions(+) diff --git a/src/audio/system/system_audio.cpp b/src/audio/system/system_audio.cpp index 8a8a1ab6..a48c9475 100644 --- a/src/audio/system/system_audio.cpp +++ b/src/audio/system/system_audio.cpp @@ -54,13 +54,25 @@ void SystemAudio::BindToVoice(DiscordClient &discord) noexcept { } void SystemAudio::OnVoiceUserConnect(Snowflake user_id, Snowflake channel_id) noexcept { + if (IsCurrentVoiceChannel(!channel_id)) { + return; + } + PlaySound(SystemSound::VoiceConnected); } void SystemAudio::OnVoiceUserDisconnect(Snowflake user_id, Snowflake channel_id) noexcept { + if (!IsCurrentVoiceChannel(channel_id)) { + return; + } + PlaySound(SystemSound::VoiceDisconnected); } +bool SystemAudio::IsCurrentVoiceChannel(Snowflake channel_id) noexcept { + return Abaddon::Get().GetDiscordClient().GetVoiceChannelID() == channel_id; +} + #endif } diff --git a/src/audio/system/system_audio.hpp b/src/audio/system/system_audio.hpp index 58ef34f9..ef3aac60 100644 --- a/src/audio/system/system_audio.hpp +++ b/src/audio/system/system_audio.hpp @@ -35,6 +35,8 @@ class SystemAudio : public sigc::trackable { #ifdef WITH_VOICE void OnVoiceUserConnect(Snowflake user_id, Snowflake channel_id) noexcept; void OnVoiceUserDisconnect(Snowflake user_id, Snowflake channel_id) noexcept; + + static bool IsCurrentVoiceChannel(Snowflake channel_id) noexcept; #endif AudioEngine m_engine; From c5fbe3909cfd481e08c50edc494830eb965fa2f6 Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Mon, 3 Jun 2024 22:03:41 +0300 Subject: [PATCH 27/39] Don't copy in capture signal --- src/discord/voiceclient.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/discord/voiceclient.cpp b/src/discord/voiceclient.cpp index 333bcfb7..4e9578e7 100644 --- a/src/discord/voiceclient.cpp +++ b/src/discord/voiceclient.cpp @@ -152,7 +152,7 @@ DiscordVoiceClient::DiscordVoiceClient() // idle or else singleton deadlock Glib::signal_idle().connect_once([this]() { auto &capture = Abaddon::Get().GetAudio().GetVoice().GetCapture(); - capture.GetCaptureSignal().connect([this](const std::vector opus) { + capture.GetCaptureSignal().connect([this](const std::vector &opus) { if (IsConnected()) { m_udp.SendEncrypted(opus.data(), opus.size()); } From d9373a2979d6948a1b0abf4891643289fdd25452 Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Mon, 3 Jun 2024 22:08:05 +0300 Subject: [PATCH 28/39] i can't type --- src/audio/system/system_audio.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/audio/system/system_audio.cpp b/src/audio/system/system_audio.cpp index a48c9475..954b6ca1 100644 --- a/src/audio/system/system_audio.cpp +++ b/src/audio/system/system_audio.cpp @@ -54,7 +54,7 @@ void SystemAudio::BindToVoice(DiscordClient &discord) noexcept { } void SystemAudio::OnVoiceUserConnect(Snowflake user_id, Snowflake channel_id) noexcept { - if (IsCurrentVoiceChannel(!channel_id)) { + if (!IsCurrentVoiceChannel(!channel_id)) { return; } From a762623ba86e745eadbaf428773fbe548584d78e Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Mon, 3 Jun 2024 22:08:55 +0300 Subject: [PATCH 29/39] i can't type 2 --- src/audio/system/system_audio.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/audio/system/system_audio.cpp b/src/audio/system/system_audio.cpp index 954b6ca1..1529c1b2 100644 --- a/src/audio/system/system_audio.cpp +++ b/src/audio/system/system_audio.cpp @@ -54,7 +54,7 @@ void SystemAudio::BindToVoice(DiscordClient &discord) noexcept { } void SystemAudio::OnVoiceUserConnect(Snowflake user_id, Snowflake channel_id) noexcept { - if (!IsCurrentVoiceChannel(!channel_id)) { + if (!IsCurrentVoiceChannel(channel_id)) { return; } From 0558de16291cb626aab939cd9d92ff253e89e3c3 Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Tue, 4 Jun 2024 18:48:18 +0300 Subject: [PATCH 30/39] Make ringbuffer more readable and use std::copy_n --- src/audio/miniaudio/ma_pcm_rb.cpp | 61 +++++++++++++++++-------------- src/audio/miniaudio/ma_pcm_rb.hpp | 3 ++ 2 files changed, 37 insertions(+), 27 deletions(-) diff --git a/src/audio/miniaudio/ma_pcm_rb.cpp b/src/audio/miniaudio/ma_pcm_rb.cpp index 249af47a..5c57982f 100644 --- a/src/audio/miniaudio/ma_pcm_rb.cpp +++ b/src/audio/miniaudio/ma_pcm_rb.cpp @@ -29,20 +29,7 @@ void MaPCMRingBuffer::Read(OutputBuffer output) noexcept { // Try twice in case of wrap around while (read < total_frames && tries < 2) { - uint32_t frames = total_frames - read; - float* read_ptr; - - ma_pcm_rb_acquire_read(m_ringbuffer.get(), &frames, reinterpret_cast(&read_ptr)); - - const auto start = read_ptr; - const auto end = start + frames * m_channels + 1; - const auto result = output.begin() + read * m_channels; - - std::copy(start, end, result); - - ma_pcm_rb_commit_read(m_ringbuffer.get(), frames); - - read += frames; + read += DoRead(output, read, total_frames); tries++; } } @@ -55,19 +42,7 @@ void MaPCMRingBuffer::Write(InputBuffer input) noexcept { // Try twice in case of wrap around while (written < total_frames && tries < 2) { - uint32_t frames = total_frames - written; - float* write_ptr; - - ma_pcm_rb_acquire_write(m_ringbuffer.get(), &frames, reinterpret_cast(&write_ptr)); - - const auto start = input.begin() + written * m_channels; - const auto end = start + frames * m_channels + 1; - - std::copy(start, end, write_ptr); - - ma_pcm_rb_commit_write(m_ringbuffer.get(), frames); - - written += frames; + written += DoWrite(input, written, total_frames); tries++; } } @@ -85,4 +60,36 @@ uint32_t MaPCMRingBuffer::GetAvailableWriteFrames() noexcept { } +uint32_t MaPCMRingBuffer::DoRead(OutputBuffer output, uint32_t read_frames, uint32_t total_frames) noexcept { + auto frames = total_frames - read_frames; + float* read_ptr; + + ma_pcm_rb_acquire_read(m_ringbuffer.get(), &frames, reinterpret_cast(&read_ptr)); + + const auto output_ptr = output.begin() + read_frames * m_channels; + const auto samples = frames * m_channels; + + std::copy_n(read_ptr, samples, output_ptr); + + ma_pcm_rb_commit_read(m_ringbuffer.get(), frames); + + return frames; +} + +uint32_t MaPCMRingBuffer::DoWrite(InputBuffer input, uint32_t written_frames, uint32_t total_frames) noexcept { + auto frames = total_frames - written_frames; + float* write_ptr; + + ma_pcm_rb_acquire_write(m_ringbuffer.get(), &frames, reinterpret_cast(&write_ptr)); + + const auto input_ptr = input.begin() + written_frames * m_channels; + const auto samples = frames * m_channels; + + std::copy_n(input_ptr, samples, write_ptr); + + ma_pcm_rb_commit_write(m_ringbuffer.get(), frames); + + return frames; +} + } diff --git a/src/audio/miniaudio/ma_pcm_rb.hpp b/src/audio/miniaudio/ma_pcm_rb.hpp index c667cdd5..432ec685 100644 --- a/src/audio/miniaudio/ma_pcm_rb.hpp +++ b/src/audio/miniaudio/ma_pcm_rb.hpp @@ -15,6 +15,9 @@ class MaPCMRingBuffer { uint32_t GetAvailableReadFrames() noexcept; uint32_t GetAvailableWriteFrames() noexcept; private: + uint32_t DoRead(OutputBuffer output, uint32_t read_frames, uint32_t total_frames) noexcept; + uint32_t DoWrite(InputBuffer input, uint32_t written_frames, uint32_t total_frames) noexcept; + struct RingBufferDeleter { void operator()(ma_pcm_rb* ptr) noexcept { ma_pcm_rb_uninit(ptr); From 0930bd7d9072c675e92973d5e865c5d9ef0da93e Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Tue, 4 Jun 2024 19:36:52 +0300 Subject: [PATCH 31/39] Fix wrong max bitrate and default EncodingApplication --- src/audio/voice/capture/voice_capture.cpp | 2 +- src/audio/voice/opus/opus_encoder.cpp | 1 + src/audio/voice/opus/opus_encoder.hpp | 2 +- src/windows/voicesettingswindow.cpp | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/audio/voice/capture/voice_capture.cpp b/src/audio/voice/capture/voice_capture.cpp index 375854de..9e766d64 100644 --- a/src/audio/voice/capture/voice_capture.cpp +++ b/src/audio/voice/capture/voice_capture.cpp @@ -70,7 +70,7 @@ void VoiceCapture::StartEncoder() noexcept { settings.channels = CAPTURE_CHANNELS; settings.bitrate = 64000; settings.signal_hint = SignalHint::Auto; - settings.application = EncodingApplication::Audio; + settings.application = EncodingApplication::VOIP; auto encoder = OpusEncoder::Create(settings); if (!encoder) { diff --git a/src/audio/voice/opus/opus_encoder.cpp b/src/audio/voice/opus/opus_encoder.cpp index 7d0a4775..df9413e6 100644 --- a/src/audio/voice/opus/opus_encoder.cpp +++ b/src/audio/voice/opus/opus_encoder.cpp @@ -72,6 +72,7 @@ void OpusEncoder::SetEncodingApplication(EncodingApplication application) noexce } m_encoder.reset(encoder); + m_application = application; SetBitrate(m_bitrate); SetSignalHint(m_signal_hint); diff --git a/src/audio/voice/opus/opus_encoder.hpp b/src/audio/voice/opus/opus_encoder.hpp index 680361f9..3328df5f 100644 --- a/src/audio/voice/opus/opus_encoder.hpp +++ b/src/audio/voice/opus/opus_encoder.hpp @@ -27,7 +27,7 @@ class OpusEncoder { int channels = 2; int32_t bitrate = 64000; SignalHint signal_hint = SignalHint::Auto; - EncodingApplication application = EncodingApplication::Audio; + EncodingApplication application = EncodingApplication::VOIP; }; static std::optional Create(EncoderSettings settings) noexcept; diff --git a/src/windows/voicesettingswindow.cpp b/src/windows/voicesettingswindow.cpp index d5df26b5..395a7843 100644 --- a/src/windows/voicesettingswindow.cpp +++ b/src/windows/voicesettingswindow.cpp @@ -117,7 +117,7 @@ VoiceSettingsWindow::VoiceSettingsWindow() if (value <= 99.9) { encoder.SetBitrate(static_cast(scaled)); } else { - encoder.SetBitrate(OPUS_BITRATE_MAX); + encoder.SetBitrate(MAX_BITRATE); } }); From bd452549d143da329bf6f24aedfa0bd3341b221e Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Tue, 4 Jun 2024 19:38:15 +0300 Subject: [PATCH 32/39] Implement separate sources for voice --- src/audio/manager.cpp | 24 ++++++++---- src/audio/manager.hpp | 3 ++ src/audio/voice/playback/client.cpp | 42 ++++++++++++++++++++- src/audio/voice/playback/client.hpp | 11 +++++- src/audio/voice/playback/client_store.cpp | 5 ++- src/audio/voice/playback/voice_playback.cpp | 8 ++++ src/audio/voice/playback/voice_playback.hpp | 2 + src/settings.cpp | 3 +- src/settings.hpp | 1 + 9 files changed, 86 insertions(+), 13 deletions(-) diff --git a/src/audio/manager.cpp b/src/audio/manager.cpp index b1e8a169..642315e7 100644 --- a/src/audio/manager.cpp +++ b/src/audio/manager.cpp @@ -124,18 +124,14 @@ std::vector AudioManager::ParseBackendsList(const Glib::ustring &lis return backends; } -#ifdef WITH_VOICE - -AbaddonClient::Audio::VoiceAudio& AudioManager::GetVoice() noexcept { - return *m_voice; +AbaddonClient::Audio::Context& AudioManager::GetContext() noexcept { + return *m_context; } -const AbaddonClient::Audio::VoiceAudio& AudioManager::GetVoice() const noexcept { - return *m_voice; +const AbaddonClient::Audio::Context& AudioManager::GetContext() const noexcept { + return *m_context; } -#endif - AbaddonClient::Audio::SystemAudio& AudioManager::GetSystem() noexcept { return *m_system; } @@ -144,4 +140,16 @@ const AbaddonClient::Audio::SystemAudio& AudioManager::GetSystem() const noexcep return *m_system; } +#ifdef WITH_VOICE + +AbaddonClient::Audio::VoiceAudio& AudioManager::GetVoice() noexcept { + return *m_voice; +} + +const AbaddonClient::Audio::VoiceAudio& AudioManager::GetVoice() const noexcept { + return *m_voice; +} + +#endif + #endif diff --git a/src/audio/manager.hpp b/src/audio/manager.hpp index 5aa4c376..b3ec3394 100644 --- a/src/audio/manager.hpp +++ b/src/audio/manager.hpp @@ -27,6 +27,9 @@ class AudioManager { AudioDevices &GetDevices(); + AbaddonClient::Audio::Context& GetContext() noexcept; + const AbaddonClient::Audio::Context& GetContext() const noexcept; + AbaddonClient::Audio::SystemAudio& GetSystem() noexcept; const AbaddonClient::Audio::SystemAudio& GetSystem() const noexcept; diff --git a/src/audio/voice/playback/client.cpp b/src/audio/voice/playback/client.cpp index e5583f14..3d027b27 100644 --- a/src/audio/voice/playback/client.cpp +++ b/src/audio/voice/playback/client.cpp @@ -1,11 +1,32 @@ #include "client.hpp" +#include "constants.hpp" + namespace AbaddonClient::Audio::Voice::Playback { -Client::Client(Opus::OpusDecoder &&decoder, VoiceBuffer &&buffer, DecodePool &decode_pool) noexcept : +void client_callback(ma_device* pDevice, void* pOutput, const void* pInput, ma_uint32 frameCount) { + const auto playback_active = Abaddon::Get().GetAudio().GetVoice().GetPlayback().GetActive(); + if (!playback_active) { + return; + } + + auto client = static_cast(pDevice->pUserData); + auto buffer = OutputBuffer(static_cast(pOutput), frameCount * RTP_CHANNELS); + + client->WriteAudio(buffer); + AudioUtils::ClampToFloatRange(buffer); +} + +Client::Client(Context &context, Opus::OpusDecoder &&decoder, VoiceBuffer &&buffer, DecodePool &decode_pool) noexcept : + m_device(context, GetDeviceConfig(), context.GetActivePlaybackID()), m_decoder( std::make_shared< Mutex >(std::move(decoder)) ), m_buffer( std::make_shared( std::move(buffer) )), - m_decode_pool(decode_pool) {} + m_decode_pool(decode_pool) +{ + if (Abaddon::Get().GetSettings().SeparateSources) { + m_device.Start(); + } +} void Client::DecodeFromRTP(std::vector &&rtp) noexcept { if (m_muted) { @@ -57,4 +78,21 @@ const PeakMeter& Client::GetPeakMeter() const noexcept { return m_peak_meter; } +ma_device_config Client::GetDeviceConfig() noexcept { + auto config = ma_device_config_init(ma_device_type_playback); + config.playback.format = RTP_FORMAT; + config.sampleRate = RTP_SAMPLE_RATE; + config.playback.channels = RTP_CHANNELS; + config.pulse.pStreamNamePlayback = "Abaddon (User)"; + + config.noClip = true; + config.noFixedSizedCallback = true; + config.performanceProfile = ma_performance_profile_low_latency; + + config.dataCallback = client_callback; + config.pUserData = this; + + return config; +} + } diff --git a/src/audio/voice/playback/client.hpp b/src/audio/voice/playback/client.hpp index 7543de93..2a5874f3 100644 --- a/src/audio/voice/playback/client.hpp +++ b/src/audio/voice/playback/client.hpp @@ -3,13 +3,16 @@ #include "audio/voice/opus/opus_decoder.hpp" #include "audio/voice/peak_meter/peak_meter.hpp" +#include "audio/audio_device.hpp" +#include "audio/context.hpp" + #include "decode_pool.hpp" namespace AbaddonClient::Audio::Voice::Playback { class Client { public: - Client(Opus::OpusDecoder &&decoder, VoiceBuffer &&buffer, DecodePool &decode_pool) noexcept; + Client(Context &context, Opus::OpusDecoder &&decoder, VoiceBuffer &&buffer, DecodePool &decode_pool) noexcept; void DecodeFromRTP(std::vector &&rtp) noexcept; void WriteAudio(OutputBuffer output) noexcept; @@ -25,6 +28,10 @@ class Client { std::atomic Volume = 1.0; private: + ma_device_config GetDeviceConfig() noexcept; + + AudioDevice m_device; + SharedDecoder m_decoder; SharedBuffer m_buffer; @@ -32,6 +39,8 @@ class Client { PeakMeter m_peak_meter; std::atomic m_muted = false; + + friend void client_callback(ma_device* pDevice, void* pOutput, const void* pInput, ma_uint32 frameCount); }; } diff --git a/src/audio/voice/playback/client_store.cpp b/src/audio/voice/playback/client_store.cpp index 4382b282..6492f7f2 100644 --- a/src/audio/voice/playback/client_store.cpp +++ b/src/audio/voice/playback/client_store.cpp @@ -27,10 +27,13 @@ void ClientStore::AddClient(ClientID id) noexcept { m_decode_pool.AddDecoder(); + auto &context = Abaddon::Get().GetAudio().GetContext(); + clients->emplace( std::piecewise_construct, std::forward_as_tuple(id), - std::forward_as_tuple(std::move(*decoder), std::move(*buffer), m_decode_pool)); + std::forward_as_tuple(context, std::move(*decoder), std::move(*buffer), m_decode_pool) + ); } void ClientStore::RemoveClient(ClientID id) noexcept { diff --git a/src/audio/voice/playback/voice_playback.cpp b/src/audio/voice/playback/voice_playback.cpp index 9fa0c9eb..e06956df 100644 --- a/src/audio/voice/playback/voice_playback.cpp +++ b/src/audio/voice/playback/voice_playback.cpp @@ -52,6 +52,10 @@ void VoicePlayback::OnUserDisconnect(Snowflake user_id, Snowflake channel_id) no } void VoicePlayback::Start() noexcept { + if (Abaddon::Get().GetSettings().SeparateSources) { + return; + } + m_device.Start(); } @@ -68,6 +72,10 @@ void VoicePlayback::SetActive(bool active) noexcept { m_active = active; } +bool VoicePlayback::GetActive() const noexcept { + return m_active; +} + void VoicePlayback::SetPlaybackDevice(const ma_device_id &device_id) noexcept { spdlog::get("voice")->info("Setting playback device"); diff --git a/src/audio/voice/playback/voice_playback.hpp b/src/audio/voice/playback/voice_playback.hpp index 4f9a55fc..f49c0890 100644 --- a/src/audio/voice/playback/voice_playback.hpp +++ b/src/audio/voice/playback/voice_playback.hpp @@ -26,6 +26,8 @@ class VoicePlayback : public sigc::trackable { void Stop() noexcept; void SetActive(bool active) noexcept; + bool GetActive() const noexcept; + void SetPlaybackDevice(const ma_device_id &device_id) noexcept; Playback::ClientStore& GetClientStore() noexcept; diff --git a/src/settings.cpp b/src/settings.cpp index fc76ddb5..ffdc6205 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -130,6 +130,7 @@ void SettingsManager::DefineSettings() { AddSetting("voice", "vad", "gate"s, &Settings::VAD); #endif AddSetting("voice", "backends", ""s, &Settings::Backends); + AddSetting("voice", "separate_sources", false, &Settings::SeparateSources); } void SettingsManager::ReadSettings() { @@ -208,4 +209,4 @@ void SettingsManager::Close() { spdlog::get("ui")->error("Failed to save settings Keyfile: {}", e.what().c_str()); } } -} \ No newline at end of file +} diff --git a/src/settings.hpp b/src/settings.hpp index 5805452b..608dfff0 100644 --- a/src/settings.hpp +++ b/src/settings.hpp @@ -52,6 +52,7 @@ class SettingsManager { // [voice] std::string VAD; std::string Backends; + bool SeparateSources; // [windows] bool HideConsole; From 9d5eb7342e1dfdf34c0b11e31e34907f8ca18eac Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Tue, 11 Jun 2024 21:40:32 +0300 Subject: [PATCH 33/39] Fix includes, include audio sources only when needed --- CMakeLists.txt | 10 ++++++++++ src/abaddon.cpp | 7 +++++-- src/abaddon.hpp | 12 ++++++++---- src/audio/devices.cpp | 3 --- src/audio/devices.hpp | 2 -- src/audio/manager.cpp | 16 ---------------- src/audio/manager.hpp | 11 ++++------- src/audio/miniaudio/ma_pcm_rb.hpp | 2 ++ 8 files changed, 29 insertions(+), 34 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 236dbd21..b26fe066 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,6 +49,14 @@ file(GLOB_RECURSE ABADDON_SOURCES list(FILTER ABADDON_SOURCES EXCLUDE REGEX ".*notifier_gio\\.cpp$") list(FILTER ABADDON_SOURCES EXCLUDE REGEX ".*notifier_fallback\\.cpp$") +if (NOT ENABLE_VOICE) + list(FILTER ABADDON_SOURCES EXCLUDE REGEX "src/audio/voice/.*") +endif () + +if (NOT (ENABLE_VOICE OR ENABLE_NOTIFICATION_SOUNDS)) + list(FILTER ABADDON_SOURCES EXCLUDE REGEX "src/audio/.*") +endif () + add_executable(abaddon ${ABADDON_SOURCES}) target_include_directories(abaddon PUBLIC ${PROJECT_SOURCE_DIR}/src) target_include_directories(abaddon PUBLIC ${PROJECT_BINARY_DIR}) @@ -216,3 +224,5 @@ set(ABADDON_COMPILER_DEFS "" CACHE STRING "Additional compiler definitions") foreach (COMPILER_DEF IN LISTS ABADDON_COMPILER_DEFS) target_compile_definitions(abaddon PRIVATE "${COMPILER_DEF}") endforeach () + +set_property(TARGET abaddon PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE) diff --git a/src/abaddon.cpp b/src/abaddon.cpp index 370b310e..20c8b5bc 100644 --- a/src/abaddon.cpp +++ b/src/abaddon.cpp @@ -7,7 +7,6 @@ #include #include #include "platform.hpp" -#include "audio/manager.hpp" #include "discord/discord.hpp" #include "dialogs/token.hpp" #include "dialogs/confirm.hpp" @@ -50,11 +49,15 @@ void macOSThemeChangedCallback(CFNotificationCenterRef center, void *observer, C #pragma comment(lib, "crypt32.lib") #endif +#ifdef WITH_MINIAUDIO +#include "audio/manager.hpp" +#endif + Abaddon::Abaddon() : m_settings(Platform::FindConfigFile()) , m_discord(GetSettings().UseMemoryDB) // stupid but easy , m_emojis(GetResPath("/emojis.db")) -#ifdef WITH_VOICE +#ifdef WITH_MINIAUDIO , m_audio(GetSettings().Backends, m_discord) #endif { diff --git a/src/abaddon.hpp b/src/abaddon.hpp index 6093523f..1402b81c 100644 --- a/src/abaddon.hpp +++ b/src/abaddon.hpp @@ -12,12 +12,13 @@ #include "imgmanager.hpp" #include "emojis.hpp" #include "notifications/notifications.hpp" + +#ifdef WITH_MINIAUDIO #include "audio/manager.hpp" +#endif #define APP_TITLE "Abaddon" -class AudioManager; - class Abaddon { private: Abaddon(); @@ -72,7 +73,7 @@ class Abaddon { ImageManager &GetImageManager(); EmojiResource &GetEmojis(); -#ifdef WITH_VOICE +#ifdef WITH_MINIAUDIO AudioManager &GetAudio(); #endif @@ -174,8 +175,11 @@ class Abaddon { ImageManager m_img_mgr; EmojiResource m_emojis; -#ifdef WITH_VOICE +#ifdef WITH_MINIAUDIO AudioManager m_audio; +#endif + +#ifdef WITH_VOICE Gtk::Window *m_voice_window = nullptr; #endif diff --git a/src/audio/devices.cpp b/src/audio/devices.cpp index bff013f9..515974d3 100644 --- a/src/audio/devices.cpp +++ b/src/audio/devices.cpp @@ -1,5 +1,3 @@ -#ifdef WITH_VOICE - // clang-format off #include "devices.hpp" @@ -118,4 +116,3 @@ AudioDevices::CaptureColumns::CaptureColumns() { add(Name); add(DeviceID); } -#endif diff --git a/src/audio/devices.hpp b/src/audio/devices.hpp index 8c7d0523..d42c5625 100644 --- a/src/audio/devices.hpp +++ b/src/audio/devices.hpp @@ -1,5 +1,4 @@ #pragma once -#ifdef WITH_MINIAUDIO // clang-format off @@ -58,4 +57,3 @@ class AudioDevices { Gtk::TreeModel::iterator m_active_capture_iter; Gtk::TreeModel::iterator m_default_capture_iter; }; -#endif diff --git a/src/audio/manager.cpp b/src/audio/manager.cpp index 642315e7..0cd63d00 100644 --- a/src/audio/manager.cpp +++ b/src/audio/manager.cpp @@ -1,18 +1,4 @@ -#ifdef WITH_MINIAUDIO -// clang-format off - -#ifdef _WIN32 - #include -#endif - #include "manager.hpp" -#include "abaddon.hpp" -#include -#include -#include -#include -#include -// clang-format on void mgr_log_callback(void *pUserData, ma_uint32 level, const char *pMessage) { auto *log = static_cast(pUserData); @@ -151,5 +137,3 @@ const AbaddonClient::Audio::VoiceAudio& AudioManager::GetVoice() const noexcept } #endif - -#endif diff --git a/src/audio/manager.hpp b/src/audio/manager.hpp index b3ec3394..59ece484 100644 --- a/src/audio/manager.hpp +++ b/src/audio/manager.hpp @@ -1,16 +1,14 @@ #pragma once -#ifdef WITH_MINIAUDIO + // clang-format off -#include -#include -#include -#include -#include #include +#include #include "devices.hpp" +#include "audio/context.hpp" + #ifdef WITH_VOICE #include "voice/voice_audio.hpp" #endif @@ -59,4 +57,3 @@ class AudioManager { bool m_ok = false; }; -#endif diff --git a/src/audio/miniaudio/ma_pcm_rb.hpp b/src/audio/miniaudio/ma_pcm_rb.hpp index 432ec685..92c3e794 100644 --- a/src/audio/miniaudio/ma_pcm_rb.hpp +++ b/src/audio/miniaudio/ma_pcm_rb.hpp @@ -2,6 +2,8 @@ #include +#include "audio/utils.hpp" + namespace AbaddonClient::Audio::Miniaudio { class MaPCMRingBuffer { From 12e701cabb7ebebb330718ced6652538a239e6a1 Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Wed, 12 Jun 2024 18:18:41 +0300 Subject: [PATCH 34/39] Disable LTO on Windows --- CMakeLists.txt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index b26fe066..c1850af0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -225,4 +225,9 @@ foreach (COMPILER_DEF IN LISTS ABADDON_COMPILER_DEFS) target_compile_definitions(abaddon PRIVATE "${COMPILER_DEF}") endforeach () -set_property(TARGET abaddon PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE) +# LTO breaks on Windows +# Due to https://gcc.gnu.org/bugzilla/show_bug.cgi?id=108383 +# And other weirdness +if (NOT WIN32) + set_property(TARGET abaddon PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE) +endif () From 98306b260d447291d98e06a7e26a40e00d41b4a2 Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Wed, 12 Jun 2024 23:55:12 +0300 Subject: [PATCH 35/39] Document separate_sources option in README --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c2fab190..4e0437e8 100644 --- a/README.md +++ b/README.md @@ -338,10 +338,11 @@ For example, memory_db would be set by adding `memory_db = true` under the line #### voice -| Setting | Type | Default | Description | -|------------|--------|------------------------------------|----------------------------------------------------------------------------------------------------------------------------| -| `vad` | string | rnnoise if enabled, gate otherwise | Method used for voice activity detection. Changeable in UI | -| `backends` | string | empty | Change backend priority when initializing miniaudio: `wasapi;dsound;winmm;coreaudio;sndio;audio4;oss;pulseaudio;alsa;jack` | +| Setting | Type | Default | Description | +|--------------------|---------|------------------------------------|----------------------------------------------------------------------------------------------------------------------------| +| `vad` | string | rnnoise if enabled, gate otherwise | Method used for voice activity detection. Changeable in UI | +| `backends` | string | empty | Change backend priority when initializing miniaudio: `wasapi;dsound;winmm;coreaudio;sndio;audio4;oss;pulseaudio;alsa;jack` | +| `separate_sources` | boolean | false | Spawn separate audio sources for each user in voice call, useful for multitrack recording on Linux | #### windows From 2d96b38ebfe346e41ae59f362336a3df6b07c1bb Mon Sep 17 00:00:00 2001 From: Ryze312 <50497128+ryze312@users.noreply.github.com> Date: Thu, 13 Jun 2024 12:45:18 +0300 Subject: [PATCH 36/39] Add missing headers --- src/audio/audio_engine.cpp | 3 +++ src/audio/audio_engine.hpp | 4 ++++ src/audio/context.cpp | 2 ++ src/audio/miniaudio/ma_context.cpp | 2 ++ src/audio/miniaudio/ma_context.hpp | 5 ++++- src/audio/miniaudio/ma_device.cpp | 2 ++ src/audio/miniaudio/ma_device.hpp | 3 +++ src/audio/miniaudio/ma_engine.cpp | 4 ++++ src/audio/miniaudio/ma_engine.hpp | 4 ++++ src/audio/miniaudio/ma_log.cpp | 4 ++++ src/audio/miniaudio/ma_log.hpp | 3 +++ src/audio/miniaudio/ma_pcm_rb.cpp | 4 ++++ src/audio/miniaudio/ma_pcm_rb.hpp | 4 ++++ src/audio/system/system_audio.cpp | 2 ++ src/audio/system/system_audio.hpp | 6 ++++++ src/audio/utils.hpp | 3 +++ src/audio/voice/capture/constants.hpp | 3 +++ src/audio/voice/capture/effects/gate.hpp | 2 ++ src/audio/voice/capture/effects/noise.hpp | 7 ++++--- src/audio/voice/capture/voice_capture.cpp | 2 ++ src/audio/voice/capture/voice_capture.hpp | 2 ++ src/audio/voice/capture/voice_effects.cpp | 2 ++ src/audio/voice/opus/opus_decoder.cpp | 2 ++ src/audio/voice/opus/opus_decoder.hpp | 4 ++++ src/audio/voice/opus/opus_encoder.cpp | 2 ++ src/audio/voice/opus/opus_encoder.hpp | 5 +++++ src/audio/voice/peak_meter/peak_meter.cpp | 2 ++ src/audio/voice/peak_meter/peak_meter.hpp | 3 +-- src/audio/voice/playback/client.cpp | 3 ++- src/audio/voice/playback/client_store.cpp | 4 ++++ src/audio/voice/playback/client_store.hpp | 3 +++ src/audio/voice/playback/decode_pool.cpp | 2 ++ src/audio/voice/playback/voice_playback.cpp | 2 ++ src/misc/channel.hpp | 2 ++ src/misc/mutex.hpp | 5 +++++ src/misc/slice.hpp | 3 +++ src/platform.cpp | 9 ++++++--- 37 files changed, 114 insertions(+), 10 deletions(-) diff --git a/src/audio/audio_engine.cpp b/src/audio/audio_engine.cpp index 1597b66f..2689abe5 100644 --- a/src/audio/audio_engine.cpp +++ b/src/audio/audio_engine.cpp @@ -1,5 +1,8 @@ #include "audio_engine.hpp" +#include +#include + namespace AbaddonClient::Audio { AudioEngine::AudioEngine(Context &context) noexcept : diff --git a/src/audio/audio_engine.hpp b/src/audio/audio_engine.hpp index 09d06b5e..8f1ccc46 100644 --- a/src/audio/audio_engine.hpp +++ b/src/audio/audio_engine.hpp @@ -1,9 +1,13 @@ #pragma once +#include + +#include #include #include "misc/mutex.hpp" +#include "audio/context.hpp" #include "miniaudio/ma_engine.hpp" namespace AbaddonClient::Audio { diff --git a/src/audio/context.cpp b/src/audio/context.cpp index b77a6bfe..c0e4e787 100644 --- a/src/audio/context.cpp +++ b/src/audio/context.cpp @@ -1,5 +1,7 @@ #include "context.hpp" +#include + namespace AbaddonClient::Audio { Context::Context(Miniaudio::MaContext &&context) noexcept : diff --git a/src/audio/miniaudio/ma_context.cpp b/src/audio/miniaudio/ma_context.cpp index ce234c75..14f970d6 100644 --- a/src/audio/miniaudio/ma_context.cpp +++ b/src/audio/miniaudio/ma_context.cpp @@ -1,5 +1,7 @@ #include "ma_context.hpp" +#include + namespace AbaddonClient::Audio::Miniaudio { MaContext::MaContext(ContextPtr &&context) noexcept : diff --git a/src/audio/miniaudio/ma_context.hpp b/src/audio/miniaudio/ma_context.hpp index 79a72bd1..4d441bc4 100644 --- a/src/audio/miniaudio/ma_context.hpp +++ b/src/audio/miniaudio/ma_context.hpp @@ -1,9 +1,12 @@ #pragma once -#include "misc/slice.hpp" +#include +#include #include +#include "misc/slice.hpp" + namespace AbaddonClient::Audio::Miniaudio { class MaContext { diff --git a/src/audio/miniaudio/ma_device.cpp b/src/audio/miniaudio/ma_device.cpp index 3678f82a..cc0345e6 100644 --- a/src/audio/miniaudio/ma_device.cpp +++ b/src/audio/miniaudio/ma_device.cpp @@ -1,5 +1,7 @@ #include "ma_device.hpp" +#include + namespace AbaddonClient::Audio::Miniaudio { MaDevice::MaDevice(DevicePtr &&device) noexcept : diff --git a/src/audio/miniaudio/ma_device.hpp b/src/audio/miniaudio/ma_device.hpp index afd59395..41910032 100644 --- a/src/audio/miniaudio/ma_device.hpp +++ b/src/audio/miniaudio/ma_device.hpp @@ -1,5 +1,8 @@ #pragma once +#include +#include + #include #include "ma_context.hpp" diff --git a/src/audio/miniaudio/ma_engine.cpp b/src/audio/miniaudio/ma_engine.cpp index a0cc1c80..5286eb32 100644 --- a/src/audio/miniaudio/ma_engine.cpp +++ b/src/audio/miniaudio/ma_engine.cpp @@ -1,5 +1,9 @@ #include "ma_engine.hpp" +#include + +#include + namespace AbaddonClient::Audio::Miniaudio { MaEngine::MaEngine(EnginePtr &&engine) noexcept : diff --git a/src/audio/miniaudio/ma_engine.hpp b/src/audio/miniaudio/ma_engine.hpp index 85d128c7..509dc492 100644 --- a/src/audio/miniaudio/ma_engine.hpp +++ b/src/audio/miniaudio/ma_engine.hpp @@ -1,5 +1,9 @@ #pragma once +#include +#include +#include + #include namespace AbaddonClient::Audio::Miniaudio { diff --git a/src/audio/miniaudio/ma_log.cpp b/src/audio/miniaudio/ma_log.cpp index 27614c98..33481dbe 100644 --- a/src/audio/miniaudio/ma_log.cpp +++ b/src/audio/miniaudio/ma_log.cpp @@ -1,5 +1,9 @@ #include "ma_log.hpp" +#include + +#include + namespace AbaddonClient::Audio::Miniaudio { MaLog::MaLog(LogPtr &&log) noexcept : diff --git a/src/audio/miniaudio/ma_log.hpp b/src/audio/miniaudio/ma_log.hpp index e8a99ed2..ec67699b 100644 --- a/src/audio/miniaudio/ma_log.hpp +++ b/src/audio/miniaudio/ma_log.hpp @@ -1,5 +1,8 @@ #pragma once +#include +#include + #include namespace AbaddonClient::Audio::Miniaudio { diff --git a/src/audio/miniaudio/ma_pcm_rb.cpp b/src/audio/miniaudio/ma_pcm_rb.cpp index 5c57982f..ba1a9948 100644 --- a/src/audio/miniaudio/ma_pcm_rb.cpp +++ b/src/audio/miniaudio/ma_pcm_rb.cpp @@ -1,5 +1,9 @@ #include "ma_pcm_rb.hpp" +#include + +#include + namespace AbaddonClient::Audio::Miniaudio { MaPCMRingBuffer::MaPCMRingBuffer(RingBufferPtr &&ringbuffer, uint32_t channels) noexcept : diff --git a/src/audio/miniaudio/ma_pcm_rb.hpp b/src/audio/miniaudio/ma_pcm_rb.hpp index 92c3e794..b53fec67 100644 --- a/src/audio/miniaudio/ma_pcm_rb.hpp +++ b/src/audio/miniaudio/ma_pcm_rb.hpp @@ -1,5 +1,9 @@ #pragma once +#include +#include +#include + #include #include "audio/utils.hpp" diff --git a/src/audio/system/system_audio.cpp b/src/audio/system/system_audio.cpp index 1529c1b2..ba524b3e 100644 --- a/src/audio/system/system_audio.cpp +++ b/src/audio/system/system_audio.cpp @@ -1,5 +1,7 @@ #include "system_audio.hpp" +#include "abaddon.hpp" + namespace AbaddonClient::Audio { SystemAudio::SystemAudio(Context &context) noexcept : diff --git a/src/audio/system/system_audio.hpp b/src/audio/system/system_audio.hpp index ef3aac60..ef9269dd 100644 --- a/src/audio/system/system_audio.hpp +++ b/src/audio/system/system_audio.hpp @@ -1,6 +1,12 @@ #pragma once +#include + +#include + #include "audio/audio_engine.hpp" +#include "discord/discord.hpp" +#include "discord/snowflake.hpp" namespace AbaddonClient::Audio { diff --git a/src/audio/utils.hpp b/src/audio/utils.hpp index 3cbaa1ad..85019e87 100644 --- a/src/audio/utils.hpp +++ b/src/audio/utils.hpp @@ -1,5 +1,8 @@ #pragma once +#include +#include + #include "misc/slice.hpp" using InputBuffer = ConstSlice; diff --git a/src/audio/voice/capture/constants.hpp b/src/audio/voice/capture/constants.hpp index f5a41303..ae2b8eb9 100644 --- a/src/audio/voice/capture/constants.hpp +++ b/src/audio/voice/capture/constants.hpp @@ -1,5 +1,8 @@ #pragma once +#include +#include + #include constexpr ma_format CAPTURE_FORMAT = ma_format_f32; diff --git a/src/audio/voice/capture/effects/gate.hpp b/src/audio/voice/capture/effects/gate.hpp index 7c78c07c..9bdf3516 100644 --- a/src/audio/voice/capture/effects/gate.hpp +++ b/src/audio/voice/capture/effects/gate.hpp @@ -1,5 +1,7 @@ #pragma once +#include + #include "audio/utils.hpp" namespace AbaddonClient::Audio::Voice::Capture::Effects { diff --git a/src/audio/voice/capture/effects/noise.hpp b/src/audio/voice/capture/effects/noise.hpp index f106c0a7..b8824795 100644 --- a/src/audio/voice/capture/effects/noise.hpp +++ b/src/audio/voice/capture/effects/noise.hpp @@ -1,11 +1,12 @@ #pragma once -#include "misc/mutex.hpp" +#include + +#include #include "audio/voice/capture/constants.hpp" #include "audio/voice/peak_meter/peak_meter.hpp" - -#include +#include "misc/mutex.hpp" namespace AbaddonClient::Audio::Voice::Capture::Effects { diff --git a/src/audio/voice/capture/voice_capture.cpp b/src/audio/voice/capture/voice_capture.cpp index 9e766d64..9513cf61 100644 --- a/src/audio/voice/capture/voice_capture.cpp +++ b/src/audio/voice/capture/voice_capture.cpp @@ -1,6 +1,8 @@ #include "voice_capture.hpp" #include "constants.hpp" +#include + namespace AbaddonClient::Audio::Voice { using OpusEncoder = Opus::OpusEncoder; diff --git a/src/audio/voice/capture/voice_capture.hpp b/src/audio/voice/capture/voice_capture.hpp index 5e4e8553..10f5f37f 100644 --- a/src/audio/voice/capture/voice_capture.hpp +++ b/src/audio/voice/capture/voice_capture.hpp @@ -1,5 +1,7 @@ #pragma once +#include + #include "audio/audio_device.hpp" #include "audio/context.hpp" #include "audio/utils.hpp" diff --git a/src/audio/voice/capture/voice_effects.cpp b/src/audio/voice/capture/voice_effects.cpp index c30fa9a5..83768a34 100644 --- a/src/audio/voice/capture/voice_effects.cpp +++ b/src/audio/voice/capture/voice_effects.cpp @@ -1,5 +1,7 @@ #include "voice_effects.hpp" +#include + namespace AbaddonClient::Audio::Voice::Capture { bool VoiceEffects::PassesVAD(InputBuffer buffer, float current_peak) noexcept { diff --git a/src/audio/voice/opus/opus_decoder.cpp b/src/audio/voice/opus/opus_decoder.cpp index 3d7bf5d4..5a46936f 100644 --- a/src/audio/voice/opus/opus_decoder.cpp +++ b/src/audio/voice/opus/opus_decoder.cpp @@ -1,5 +1,7 @@ #include "opus_decoder.hpp" +#include + namespace AbaddonClient::Audio::Voice::Opus { OpusDecoder::OpusDecoder(DecoderPtr encoder, DecoderSettings settings) noexcept : diff --git a/src/audio/voice/opus/opus_decoder.hpp b/src/audio/voice/opus/opus_decoder.hpp index 53f9d024..f7ed0b24 100644 --- a/src/audio/voice/opus/opus_decoder.hpp +++ b/src/audio/voice/opus/opus_decoder.hpp @@ -1,5 +1,9 @@ #pragma once +#include +#include +#include + #include #include "audio/utils.hpp" diff --git a/src/audio/voice/opus/opus_encoder.cpp b/src/audio/voice/opus/opus_encoder.cpp index df9413e6..e1f77856 100644 --- a/src/audio/voice/opus/opus_encoder.cpp +++ b/src/audio/voice/opus/opus_encoder.cpp @@ -1,5 +1,7 @@ #include "opus_encoder.hpp" +#include + namespace AbaddonClient::Audio::Voice::Opus { using EncoderSettings = OpusEncoder::EncoderSettings; diff --git a/src/audio/voice/opus/opus_encoder.hpp b/src/audio/voice/opus/opus_encoder.hpp index 3328df5f..d777cafb 100644 --- a/src/audio/voice/opus/opus_encoder.hpp +++ b/src/audio/voice/opus/opus_encoder.hpp @@ -1,5 +1,10 @@ #pragma once +#include +#include +#include +#include + #include #include "audio/utils.hpp" diff --git a/src/audio/voice/peak_meter/peak_meter.cpp b/src/audio/voice/peak_meter/peak_meter.cpp index 722aaf71..8d087533 100644 --- a/src/audio/voice/peak_meter/peak_meter.cpp +++ b/src/audio/voice/peak_meter/peak_meter.cpp @@ -1,5 +1,7 @@ #include "peak_meter.hpp" +#include + namespace AbaddonClient::Audio::Voice { PeakMeter::PeakMeter() noexcept { diff --git a/src/audio/voice/peak_meter/peak_meter.hpp b/src/audio/voice/peak_meter/peak_meter.hpp index c3677bcb..954121f4 100644 --- a/src/audio/voice/peak_meter/peak_meter.hpp +++ b/src/audio/voice/peak_meter/peak_meter.hpp @@ -1,10 +1,9 @@ #pragma once -#include +#include #include "audio/utils.hpp" - namespace AbaddonClient::Audio::Voice { class PeakMeter { diff --git a/src/audio/voice/playback/client.cpp b/src/audio/voice/playback/client.cpp index 3d027b27..43bb4193 100644 --- a/src/audio/voice/playback/client.cpp +++ b/src/audio/voice/playback/client.cpp @@ -1,7 +1,8 @@ #include "client.hpp" - #include "constants.hpp" +#include "abaddon.hpp" + namespace AbaddonClient::Audio::Voice::Playback { void client_callback(ma_device* pDevice, void* pOutput, const void* pInput, ma_uint32 frameCount) { diff --git a/src/audio/voice/playback/client_store.cpp b/src/audio/voice/playback/client_store.cpp index 6492f7f2..c28a50c5 100644 --- a/src/audio/voice/playback/client_store.cpp +++ b/src/audio/voice/playback/client_store.cpp @@ -1,6 +1,10 @@ #include "client_store.hpp" #include "constants.hpp" +#include + +#include "abaddon.hpp" + namespace AbaddonClient::Audio::Voice::Playback { void ClientStore::AddClient(ClientID id) noexcept { diff --git a/src/audio/voice/playback/client_store.hpp b/src/audio/voice/playback/client_store.hpp index e84d0971..e25b8122 100644 --- a/src/audio/voice/playback/client_store.hpp +++ b/src/audio/voice/playback/client_store.hpp @@ -1,6 +1,9 @@ #pragma once +#include + #include "audio/utils.hpp" +#include "misc/mutex.hpp" #include "client.hpp" #include "decode_pool.hpp" diff --git a/src/audio/voice/playback/decode_pool.cpp b/src/audio/voice/playback/decode_pool.cpp index c306b258..ba442370 100644 --- a/src/audio/voice/playback/decode_pool.cpp +++ b/src/audio/voice/playback/decode_pool.cpp @@ -1,6 +1,8 @@ #include "decode_pool.hpp" #include "constants.hpp" +#include + namespace AbaddonClient::Audio::Voice::Playback { // I have no idea what this does, I just copied it from AudioManager diff --git a/src/audio/voice/playback/voice_playback.cpp b/src/audio/voice/playback/voice_playback.cpp index e06956df..28ff7d35 100644 --- a/src/audio/voice/playback/voice_playback.cpp +++ b/src/audio/voice/playback/voice_playback.cpp @@ -1,6 +1,8 @@ #include "voice_playback.hpp" #include "constants.hpp" +#include "abaddon.hpp" + namespace AbaddonClient::Audio::Voice { void playback_callback(ma_device* pDevice, void* pOutput, const void* pInput, ma_uint32 frameCount) { diff --git a/src/misc/channel.hpp b/src/misc/channel.hpp index 830b1a27..a3e9de51 100644 --- a/src/misc/channel.hpp +++ b/src/misc/channel.hpp @@ -1,5 +1,7 @@ #pragma once +#include + #include "mutex.hpp" // Channel with bounded queue size diff --git a/src/misc/mutex.hpp b/src/misc/mutex.hpp index f647e67f..1f45a1be 100644 --- a/src/misc/mutex.hpp +++ b/src/misc/mutex.hpp @@ -1,5 +1,10 @@ #pragma once +#include +#include + +#include + // RAII style mutex guard // Unlocks mutex upon destruction template diff --git a/src/misc/slice.hpp b/src/misc/slice.hpp index b9997808..fbb83ac2 100644 --- a/src/misc/slice.hpp +++ b/src/misc/slice.hpp @@ -1,5 +1,8 @@ #pragma once +#include +#include + // C++20 span-like slice // T has to be contigious array-like initialized structure with size of "size * sizeof(T)" template diff --git a/src/platform.cpp b/src/platform.cpp index 0a697210..004ba3fe 100644 --- a/src/platform.cpp +++ b/src/platform.cpp @@ -1,9 +1,12 @@ #include "platform.hpp" +#include "util.hpp" #include #include #include #include +#include + using namespace std::literals::string_literals; #if defined(_WIN32) @@ -194,15 +197,15 @@ std::string Platform::FindConfigFile() { if (mkdir(homefolder_path, 0755) == 0) { spdlog::get("discord")->warn("created Application Support dir"); } - + char home_path[PATH_MAX]; snprintf(home_path, sizeof(home_path), "%s/%s", homefolder_path, "/abaddon.ini"); - + return home_path; } std::string Platform::FindStateCacheFolder() { - + passwd *home = getpwuid(getuid()); const char *homeDir = home->pw_dir; From 06fb2a986134eec8def60e03d0c185b29d08ec09 Mon Sep 17 00:00:00 2001 From: Ryze <50497128+ryze312@users.noreply.github.com> Date: Thu, 11 Jul 2024 22:09:30 +0300 Subject: [PATCH 37/39] Update RTP stripping --- src/audio/voice/playback/client.cpp | 4 ++-- src/audio/voice/playback/client.hpp | 2 +- src/audio/voice/playback/client_store.cpp | 4 ++-- src/audio/voice/playback/client_store.hpp | 2 +- src/audio/voice/playback/decode_pool.cpp | 13 ++--------- src/audio/voice/playback/decode_pool.hpp | 4 ++-- src/audio/voice/playback/voice_playback.cpp | 4 ++-- src/audio/voice/playback/voice_playback.hpp | 2 +- src/discord/voiceclient.cpp | 26 +++++++++++++++++---- 9 files changed, 35 insertions(+), 26 deletions(-) diff --git a/src/audio/voice/playback/client.cpp b/src/audio/voice/playback/client.cpp index 43bb4193..2f7e7e1f 100644 --- a/src/audio/voice/playback/client.cpp +++ b/src/audio/voice/playback/client.cpp @@ -29,7 +29,7 @@ Client::Client(Context &context, Opus::OpusDecoder &&decoder, VoiceBuffer &&buff } } -void Client::DecodeFromRTP(std::vector &&rtp) noexcept { +void Client::Decode(std::vector &&rtp) noexcept { if (m_muted) { return; } @@ -40,7 +40,7 @@ void Client::DecodeFromRTP(std::vector &&rtp) noexcept { m_buffer, }; - m_decode_pool.DecodeFromRTP(std::move(decode_data)); + m_decode_pool.Decode(std::move(decode_data)); } void Client::WriteAudio(OutputBuffer buffer) noexcept { diff --git a/src/audio/voice/playback/client.hpp b/src/audio/voice/playback/client.hpp index 2a5874f3..55392cdd 100644 --- a/src/audio/voice/playback/client.hpp +++ b/src/audio/voice/playback/client.hpp @@ -14,7 +14,7 @@ class Client { public: Client(Context &context, Opus::OpusDecoder &&decoder, VoiceBuffer &&buffer, DecodePool &decode_pool) noexcept; - void DecodeFromRTP(std::vector &&rtp) noexcept; + void Decode(std::vector &&rtp) noexcept; void WriteAudio(OutputBuffer output) noexcept; void ClearBuffer() noexcept; diff --git a/src/audio/voice/playback/client_store.cpp b/src/audio/voice/playback/client_store.cpp index c28a50c5..68ccc782 100644 --- a/src/audio/voice/playback/client_store.cpp +++ b/src/audio/voice/playback/client_store.cpp @@ -56,7 +56,7 @@ void ClientStore::Clear() noexcept { m_clients.Lock()->clear(); } -void ClientStore::DecodeFromRTP(ClientID id, std::vector &&data) noexcept { +void ClientStore::Decode(ClientID id, std::vector &&data) noexcept { auto clients = m_clients.Lock(); auto client = clients->find(id); @@ -65,7 +65,7 @@ void ClientStore::DecodeFromRTP(ClientID id, std::vector &&data) noexce return; } - client->second.DecodeFromRTP(std::move(data)); + client->second.Decode(std::move(data)); } void ClientStore::WriteMixed(OutputBuffer buffer) noexcept { diff --git a/src/audio/voice/playback/client_store.hpp b/src/audio/voice/playback/client_store.hpp index e25b8122..a7d7c81b 100644 --- a/src/audio/voice/playback/client_store.hpp +++ b/src/audio/voice/playback/client_store.hpp @@ -38,7 +38,7 @@ class ClientStore { using ClientMap = std::unordered_map; // Keep these two private and expose through VoicePlayback - void DecodeFromRTP(ClientID id, std::vector &&data) noexcept; + void Decode(ClientID id, std::vector &&data) noexcept; void WriteMixed(OutputBuffer buffer) noexcept; DecodePool m_decode_pool; diff --git a/src/audio/voice/playback/decode_pool.cpp b/src/audio/voice/playback/decode_pool.cpp index ba442370..7e12dcf8 100644 --- a/src/audio/voice/playback/decode_pool.cpp +++ b/src/audio/voice/playback/decode_pool.cpp @@ -5,20 +5,12 @@ namespace AbaddonClient::Audio::Voice::Playback { -// I have no idea what this does, I just copied it from AudioManager -Opus::OpusInput StripRTPExtensionHeader(ConstSlice rtp) { - if (rtp[0] == 0xbe && rtp[1] == 0xde && rtp.size() > 4) { - uint64_t offset = 4 + 4 * ((rtp[2] << 8) | rtp[3]); - return Opus::OpusInput(rtp.data() + offset, rtp.size() - offset); - } - return rtp; -} DecodePool::DecodePool() noexcept : m_pool(Pool(&DecodePool::DecodeThread, 20)) {} -void DecodePool::DecodeFromRTP(DecodeData &&decode_data) noexcept { +void DecodePool::Decode(DecodeData &&decode_data) noexcept { m_pool.SendToPool(std::move(decode_data)); } @@ -51,10 +43,9 @@ void DecodePool::DecodeThread(Channel &channel) noexcept { } void DecodePool::OnDecodeMessage(DecodeData &&message) noexcept { - auto& [rtp, decoder, buffer] = message; + auto& [opus, decoder, buffer] = message; static std::array pcm; - const auto opus = StripRTPExtensionHeader(rtp); int frames = decoder->Lock()->Decode(opus, pcm, RTP_OPUS_FRAME_SIZE); if (frames < 0) { diff --git a/src/audio/voice/playback/decode_pool.hpp b/src/audio/voice/playback/decode_pool.hpp index 96a1cc4c..cf2b625b 100644 --- a/src/audio/voice/playback/decode_pool.hpp +++ b/src/audio/voice/playback/decode_pool.hpp @@ -15,13 +15,13 @@ class DecodePool { DecodePool() noexcept; struct DecodeData { - std::vector rtp; + std::vector opus; SharedDecoder decoder; SharedBuffer buffer; }; - void DecodeFromRTP(DecodeData &&decode_data) noexcept; + void Decode(DecodeData &&decode_data) noexcept; void AddDecoder() noexcept; void RemoveDecoder() noexcept; diff --git a/src/audio/voice/playback/voice_playback.cpp b/src/audio/voice/playback/voice_playback.cpp index 28ff7d35..a22d3689 100644 --- a/src/audio/voice/playback/voice_playback.cpp +++ b/src/audio/voice/playback/voice_playback.cpp @@ -26,9 +26,9 @@ VoicePlayback::VoicePlayback(Context &context, DiscordClient &discord) noexcept .connect(sigc::mem_fun(*this, &VoicePlayback::OnUserDisconnect)); } -void VoicePlayback::OnRTPData(ClientID id, std::vector &&data) noexcept { +void VoicePlayback::OnOpusData(ClientID id, std::vector &&data) noexcept { if (m_active) { - m_clients.DecodeFromRTP(id, std::move(data)); + m_clients.Decode(id, std::move(data)); } } diff --git a/src/audio/voice/playback/voice_playback.hpp b/src/audio/voice/playback/voice_playback.hpp index f49c0890..2f3f7439 100644 --- a/src/audio/voice/playback/voice_playback.hpp +++ b/src/audio/voice/playback/voice_playback.hpp @@ -20,7 +20,7 @@ class VoicePlayback : public sigc::trackable { VoicePlayback(Context &context, DiscordClient &discord) noexcept; - void OnRTPData(ClientID id, std::vector &&data) noexcept; + void OnOpusData(ClientID id, std::vector &&data) noexcept; void Start() noexcept; void Stop() noexcept; diff --git a/src/discord/voiceclient.cpp b/src/discord/voiceclient.cpp index 4e9578e7..67cd84f7 100644 --- a/src/discord/voiceclient.cpp +++ b/src/discord/voiceclient.cpp @@ -17,6 +17,19 @@ #endif // clang-format on +size_t GetPayloadOffset(const uint8_t *buf, size_t num_bytes) { + const bool has_extension_header = (buf[0] & 0b00010000) != 0; + const int csrc_count = buf[0] & 0b00001111; + + size_t offset = 12 + csrc_count * 4; + + if (has_extension_header && num_bytes > 4) { + offset += 4 + 4 * ((buf[offset + 2] << 8) | buf[offset + 3]); + } + + return offset; +} + UDPSocket::UDPSocket() : m_socket(-1) { } @@ -465,11 +478,16 @@ void DiscordVoiceClient::OnUDPData(std::vector data) { (data[11] << 0); static std::array nonce = {}; std::memcpy(nonce.data(), data.data(), 12); - if (crypto_secretbox_open_easy(payload, payload, data.size() - 12, nonce.data(), m_secret_key.data())) { - // spdlog::get("voice")->trace("UDP payload decryption failure"); - } else { - Abaddon::Get().GetAudio().GetVoice().GetPlayback().OnRTPData(ssrc, { payload, payload + data.size() - 12 - crypto_box_MACBYTES }); + + const int error = crypto_secretbox_open_easy(payload, payload, data.size() - 12, nonce.data(), m_secret_key.data()); + if (error) { + return; } + + const size_t opus_offset = GetPayloadOffset(data.data(), data.size()); + payload = data.data() + opus_offset; + + Abaddon::Get().GetAudio().GetVoice().GetPlayback().OnOpusData(ssrc, { payload, payload + data.size() - opus_offset - crypto_box_MACBYTES }); } void DiscordVoiceClient::OnDispatch() { From 8d4384e94645878e069f99b27c29418b590946d3 Mon Sep 17 00:00:00 2001 From: Ryze <50497128+ryze312@users.noreply.github.com> Date: Thu, 11 Jul 2024 23:24:11 +0300 Subject: [PATCH 38/39] Fix left denoised channel not being written --- src/audio/voice/capture/effects/noise.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/audio/voice/capture/effects/noise.cpp b/src/audio/voice/capture/effects/noise.cpp index 910306c2..dda3338f 100644 --- a/src/audio/voice/capture/effects/noise.cpp +++ b/src/audio/voice/capture/effects/noise.cpp @@ -19,11 +19,18 @@ void Noise::Denoise(OutputBuffer buffer) noexcept { start = 1; } + // Denoise required channels auto channels = m_channels.Lock(); for (size_t channel = start; channel < channels->size(); channel++) { auto& channel_buffer = channels[channel]; channel_buffer.DenoiseChannel(buffer, channel); + } + + // Write them back + for (size_t channel = 0; channel < channels->size(); channel++) { + auto& channel_buffer = channels[channel]; + channel_buffer.WriteChannel(buffer, channel); } } From e48e487bd99b97ed2dadea89e74d5a41b872dfff Mon Sep 17 00:00:00 2001 From: Ryze <50497128+ryze312@users.noreply.github.com> Date: Fri, 12 Jul 2024 01:12:19 +0300 Subject: [PATCH 39/39] Remove duplicate definition of GetPayloadOffset --- src/discord/voiceclient.cpp | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/discord/voiceclient.cpp b/src/discord/voiceclient.cpp index e7885cdc..ec309566 100644 --- a/src/discord/voiceclient.cpp +++ b/src/discord/voiceclient.cpp @@ -17,19 +17,6 @@ #endif // clang-format on -size_t GetPayloadOffset(const uint8_t *buf, size_t num_bytes) { - const bool has_extension_header = (buf[0] & 0b00010000) != 0; - const int csrc_count = buf[0] & 0b00001111; - - size_t offset = 12 + csrc_count * 4; - - if (has_extension_header && num_bytes > 4) { - offset += 4 + 4 * ((buf[offset + 2] << 8) | buf[offset + 3]); - } - - return offset; -} - UDPSocket::UDPSocket() : m_socket(-1) { }