Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmake/MafiaNetPin.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@
# tree, so bumping the pin here and rebuilding incrementally would silently keep
# fetching the old revision. This file is the single source of truth, and there is
# no reason to let -D override the wire format of the protocol.
set(MAFIANET_PIN "a515c827e01868ecc6be26ee82e4da7a04955f77") # v0.13.0
set(MAFIANET_PIN "caf9469af4bdb7dfc83616aa3a660a5511181f5f") # v0.13.0 + RakVoice::SetMaxDecodedSpeakers
Comment thread
coderabbitai[bot] marked this conversation as resolved.
6 changes: 6 additions & 0 deletions code/framework/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ set(FRAMEWORK_CLIENT_SRC
src/integrations/client/scripting/builtins/discord.cpp

src/voice/client/mixer.cpp
src/voice/client/audio_device.cpp
src/voice/client/voice_client.cpp
)

# GUI (CEF-based)
Expand Down Expand Up @@ -363,6 +365,10 @@ if(WIN32)
set(CLIENT_SHARED_LIBS minhook SteamSDK udis86)
target_link_libraries(FrameworkClient ${CLIENT_SHARED_LIBS} ${FREETYPE_LIBRARY})

# Voice capture/playback. PUBLIC on the miniaudio side, so the feature-trimming defines
# and the include directory reach anything that pulls in the voice client.
target_link_libraries(FrameworkClient miniaudio)

# FrameworkClient-specific includes (Windows graphics, hooking, external services)
target_include_directories(FrameworkClient PRIVATE
${CMAKE_SOURCE_DIR}/vendors/minhook/include
Expand Down
12 changes: 12 additions & 0 deletions code/framework/src/core_modules.h
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ namespace Framework::Networking::Replication {


namespace Framework::Voice {
class VoiceClient;
class VoiceServer;
} // namespace Framework::Voice

Expand Down Expand Up @@ -53,6 +54,7 @@ namespace Framework {
_networkPeer = nullptr;
_replication = nullptr;
_voiceServer = nullptr;
_voiceClient = nullptr;
_scriptingModule = nullptr;
_webManager = nullptr;
_input = nullptr;
Expand All @@ -76,6 +78,11 @@ namespace Framework {
_voiceServer = voice;
}

static void SetVoiceClient(Voice::VoiceClient *voice) {
FW_ASSERT_MODULE_REGISTRATION(_voiceClient, voice, "VoiceClient");
_voiceClient = voice;
}

static void SetScriptingModule(Scripting::ScriptingModule *module) {
FW_ASSERT_MODULE_REGISTRATION(_scriptingModule, module, "ScriptingModule");
_scriptingModule = module;
Expand Down Expand Up @@ -113,6 +120,10 @@ namespace Framework {
return _voiceServer;
}

static Voice::VoiceClient *GetVoiceClient() noexcept {
return _voiceClient;
}

static Scripting::ScriptingModule *GetScriptingModule() noexcept {
return _scriptingModule;
}
Expand All @@ -137,6 +148,7 @@ namespace Framework {
static inline Networking::NetworkPeer *_networkPeer {};
static inline Networking::Replication::ReplicationManager *_replication {};
static inline Voice::VoiceServer *_voiceServer {};
static inline Voice::VoiceClient *_voiceClient {};
static inline Scripting::ScriptingModule *_scriptingModule {};
static inline GUI::Manager *_webManager {};
static inline Input::IInput *_input {};
Expand Down
38 changes: 38 additions & 0 deletions code/framework/src/integrations/client/instance.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,15 @@ namespace Framework::Integrations::Client {
}
CoreModules::SetNetworkPeer(_networkingEngine->GetNetworkClient());
Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->info("Networking engine initialized");

// Attaches RakVoice to the live peer, so it must follow the networking engine.
// The relay session itself opens later, on connect.
if (_voiceClient.Init(_networkingEngine->GetNetworkClient())) {
CoreModules::SetVoiceClient(&_voiceClient);
}
else {
Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->warn("Voice client unavailable; voice chat disabled");
}
}

CoreModules::SetWebManager(_webManager.get());
Expand Down Expand Up @@ -440,6 +449,9 @@ namespace Framework::Integrations::Client {
_presence->Shutdown();
}

// Before the networking engine: this detaches the plugin from the peer.
_voiceClient.Shutdown();

if (_networkingEngine) {
_networkingEngine->Shutdown();
}
Expand All @@ -458,6 +470,7 @@ namespace Framework::Integrations::Client {

CoreModules::SetScriptingModule(nullptr);
CoreModules::SetWebManager(nullptr);
CoreModules::SetVoiceClient(nullptr);
CoreModules::SetNetworkPeer(nullptr);
CoreModules::SetReplication(nullptr);
CoreModules::SetInput(nullptr);
Expand Down Expand Up @@ -520,6 +533,31 @@ namespace Framework::Integrations::Client {
FW_PROFILE_SCOPE_N("Client::Networking");
_networkingEngine->Update();
TrySignalConnectionSpawnReady();

// After the peer pump: RakVoice decodes inbound frames from inside RakPeer::Receive,
// so draining speakers here picks up this tick's audio rather than last tick's.
{
FW_PROFILE_SCOPE_N("Client::Voice");

// The chat box and web views belong to the framework, so it enforces this itself
// rather than trusting every mod to remember.
_voiceClient.SetInputSuppressed(_chatBox.IsInputActive() || (_webManager && _webManager->IsAnyViewFocused()));

// Speaker positions come from the replicated entity set, as the server's voice
// router gets them: an owner GUID means a player-controlled entity. Done here so
// a mod only has to supply the listener transform.
if (auto *replication = _networkingEngine->GetNetworkClient()->GetReplicationManager()) {
_voiceClient.BeginSpeakerUpdate();
replication->ForEachEntity([this](Framework::Networking::Replication::NetworkEntity *entity) {
if (entity->ownerGUID != MafiaNet::UNASSIGNED_PEER_GUID) {
_voiceClient.SetSpeakerPosition(static_cast<uint64_t>(entity->ownerGUID), entity->position);
}
});
_voiceClient.EndSpeakerUpdate();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

_voiceClient.Update();
}
}

void Instance::Render() {
Expand Down
9 changes: 9 additions & 0 deletions code/framework/src/integrations/client/instance.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
#include "networking/engine.h"
#include "networking/rpc/chat_message.h"
#include "integrations/client/ui/chat_box.h"
#include "voice/client/voice_client.h"
#include <mafianet/FileListTransferCBInterface.h>

#include <cstdint>
Expand Down Expand Up @@ -179,6 +180,10 @@ namespace Framework::Integrations::Client {

UI::ChatBox _chatBox;

// Unconditional: no InstanceOptions switch. A client with no microphone degrades to
// listen-only rather than opting out.
Voice::VoiceClient _voiceClient;

void InitNetworkingMessages();
void InitAssetDownloader();
void InitProtocolHandler();
Expand Down Expand Up @@ -295,6 +300,10 @@ namespace Framework::Integrations::Client {
return _networkingEngine.get();
}

Voice::VoiceClient &GetVoiceClient() {
return _voiceClient;
}

External::Discord::Wrapper *GetPresence() const {
return _presence.get();
}
Expand Down
172 changes: 172 additions & 0 deletions code/framework/src/voice/client/audio_device.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/*
* MafiaHub OSS license
* Copyright (c) 2026, MafiaHub. All rights reserved.
*
* This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework.
* See LICENSE file in the source repository for information regarding licensing.
*/

#include "audio_device.h"

#include <logging/logger.h>
#include <miniaudio.h>

#include <cstring>
#include <new>

namespace Framework::Voice {
namespace {
// The period is a latency hint, not a contract: a backend may ignore it, and the
// rings decouple both directions anyway.
void ApplyCommonConfig(ma_device_config &config) {
config.sampleRate = kSampleRate;
config.periodSizeInFrames = kFrameSamples;
config.performanceProfile = ma_performance_profile_low_latency;
config.noPreSilencedOutputBuffer = MA_TRUE;
}
} // namespace

void CaptureDevice::OnCapture(ma_device *device, void *output, const void *input, uint32_t frameCount) {
(void)output;

auto *self = static_cast<CaptureDevice *>(device->pUserData);
if (self == nullptr || input == nullptr) {
return;
}

// A failed Push means the main thread has not drained for a third of a second.
// Dropping beats stalling the device thread.
self->_ring.Push(static_cast<const int16_t *>(input), frameCount);
}

bool CaptureDevice::Start() {
if (_device != nullptr) {
return true;
}

ma_device_config config = ma_device_config_init(ma_device_type_capture);
config.capture.format = ma_format_s16;
config.capture.channels = kChannels;
config.dataCallback = &CaptureDevice::OnCapture;
config.pUserData = this;
ApplyCommonConfig(config);

auto *device = new (std::nothrow) ma_device {};
if (device == nullptr) {
return false;
}

if (ma_device_init(nullptr, &config, device) != MA_SUCCESS) {
delete device;
Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->warn("Voice: no usable capture device; continuing listen-only");
return false;
}

if (ma_device_start(device) != MA_SUCCESS) {
ma_device_uninit(device);
delete device;
Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->warn("Voice: capture device failed to start; continuing listen-only");
return false;
}

_device = device;
Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->debug("Voice: capture device '{}' running at {}Hz", _device->capture.name, _device->sampleRate);
return true;
}

void CaptureDevice::Stop() {
if (_device == nullptr) {
return;
}

// uninit joins the device thread, so no callback can be in flight afterwards.
ma_device_uninit(_device);
delete _device;
_device = nullptr;
_ring.Clear();
}

CaptureDevice::~CaptureDevice() {
Stop();
}

bool CaptureDevice::ReadFrame(int16_t *out) {
return _ring.Pop(out, kFrameSamples);
}

void PlaybackDevice::OnPlayback(ma_device *device, void *output, const void *input, uint32_t frameCount) {
(void)input;

auto *self = static_cast<PlaybackDevice *>(device->pUserData);
if (self == nullptr || output == nullptr) {
return;
}

auto *stereoOut = static_cast<float *>(output);

// noPreSilencedOutputBuffer is set, and the mixer accumulates.
std::memset(stereoOut, 0, static_cast<size_t>(frameCount) * 2 * sizeof(float));

if (self->_render != nullptr) {
self->_render(stereoOut, frameCount, self->_user);
}
}

bool PlaybackDevice::Start(RenderFn render, void *user) {
if (_device != nullptr) {
return true;
}

if (render == nullptr) {
return false;
}

_render = render;
_user = user;

ma_device_config config = ma_device_config_init(ma_device_type_playback);
config.playback.format = ma_format_f32;
config.playback.channels = 2;
config.dataCallback = &PlaybackDevice::OnPlayback;
config.pUserData = this;
ApplyCommonConfig(config);

auto *device = new (std::nothrow) ma_device {};
if (device == nullptr) {
return false;
}

if (ma_device_init(nullptr, &config, device) != MA_SUCCESS) {
delete device;
Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->warn("Voice: no usable playback device; remote speakers will be inaudible");
return false;
}

if (ma_device_start(device) != MA_SUCCESS) {
ma_device_uninit(device);
delete device;
Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->warn("Voice: playback device failed to start; remote speakers will be inaudible");
return false;
}

_device = device;
Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->debug("Voice: playback device '{}' running at {}Hz", _device->playback.name, _device->sampleRate);
return true;
}

void PlaybackDevice::Stop() {
if (_device == nullptr) {
return;
}

// uninit joins the device thread, so the render function cannot be running once this
// returns -- which is what lets the owner tear down the state it reads.
ma_device_uninit(_device);
delete _device;
_device = nullptr;
}

PlaybackDevice::~PlaybackDevice() {
Stop();
}
} // namespace Framework::Voice
Loading