From 2eebab443bc7988ab64d1e18767d15060f3ac8f8 Mon Sep 17 00:00:00 2001 From: Adriel Guerrero Date: Sun, 12 Apr 2026 19:46:57 -0700 Subject: [PATCH 1/4] #542 Implement voice chat capture, playback and spatialization - Integrated voice chat with proximity and spatial audio support - Added Lua API for channels, muting, and audio injection - Implemented voice packet handling and event throttling - Handled automatic state cleanup when players disconnect --- CMakeLists.txt | 2 + include/TNetwork.h | 2 +- include/TVoiceChat.h | 126 +++++++++++++++++++++++ src/TLuaEngine.cpp | 104 ++++++++++++++++++- src/TNetwork.cpp | 8 +- src/TServer.cpp | 90 +++++++++++++++++ src/TVoiceChat.cpp | 235 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 560 insertions(+), 7 deletions(-) create mode 100644 include/TVoiceChat.h create mode 100644 src/TVoiceChat.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 67e758d6..bb11935c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -47,6 +47,7 @@ set(PRJ_HEADERS include/TResourceManager.h include/TScopedTimer.h include/TServer.h + include/TVoiceChat.h include/VehicleData.h include/Env.h include/Settings.h @@ -73,6 +74,7 @@ set(PRJ_SOURCES src/TResourceManager.cpp src/TScopedTimer.cpp src/TServer.cpp + src/TVoiceChat.cpp src/VehicleData.cpp src/Env.cpp src/Settings.cpp diff --git a/include/TNetwork.h b/include/TNetwork.h index 81931d93..35e3a2e5 100644 --- a/include/TNetwork.h +++ b/include/TNetwork.h @@ -41,7 +41,7 @@ class TNetwork { void Identify(TConnection&& client); std::shared_ptr Authentication(TConnection&& ClientConnection); void SyncResources(TClient& c); - [[nodiscard]] bool UDPSend(TClient& Client, std::vector Data); + [[nodiscard]] bool UDPSend(TClient& Client, const std::vector& Data); void SendToAll(TClient* c, const std::vector& Data, bool Self, bool Rel); void UpdatePlayer(TClient& Client); diff --git a/include/TVoiceChat.h b/include/TVoiceChat.h new file mode 100644 index 00000000..90165836 --- /dev/null +++ b/include/TVoiceChat.h @@ -0,0 +1,126 @@ +// BeamMP, the BeamNG.drive multiplayer mod. +// Copyright (C) 2024 BeamMP Ltd., BeamMP team and contributors. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class TNetwork; +class TClient; + +class TVoiceChat { +public: + static TVoiceChat& Instance(); + + // Packet v2 constants (must match Launcher VoiceChat.h exactly). + // Wire layout (25 bytes, little-endian): + // [0] 'F' uint8 — packet type discriminator + // [1] version uint8 — PROTOCOL_VERSION (currently 2) + // [2] flags uint8 — FLAG_PROXIMITY | FLAG_INJECTED + // [3-4] sourceId uint16 — sender client ID (or channel ID for injected) + // [5-16] pos floatx3 — source world position XYZ + // [17-20] maxDistance float — spatialization cutoff (0 = unlimited) + // [21-24] gain float — broadcast gain [0.0, 1.0] + // [25...] opusData bytes — Opus-encoded audio payload + // IMPORTANT: bump PROTOCOL_VERSION before changing this layout in a release. + static constexpr uint8_t PROTOCOL_VERSION = 2; + static constexpr uint8_t FLAG_PROXIMITY = 0x01; + static constexpr uint8_t FLAG_INJECTED = 0x02; + static constexpr size_t HEADER_SIZE = 1 + 1 + 1 + 2 + 12 + 4 + 4; // 25 bytes + + // Proximity distance (0 = unlimited) + void SetProximityDistance(float distance); + float GetProximityDistance() const; + + // Channel management + int CreateChannel(const std::string& name); + bool DeleteChannel(int channelId); + bool AddPlayerToChannel(int playerId, int channelId); + bool RemovePlayerFromChannel(int playerId, int channelId); + bool RemovePlayerFromAllChannels(int playerId); + std::unordered_set GetChannelMembers(int channelId) const; + std::unordered_set GetPlayerChannels(int playerId) const; + bool IsPlayerInChannel(int playerId, int channelId) const; + + // Channel properties + bool SetChannelMaxDistance(int channelId, float distance); + bool SetChannelPosition(int channelId, float x, float y, float z); + bool SetChannelSpatial(int channelId, bool spatial); + + // Query API + struct ChannelInfo { + int id; + std::string name; + }; + std::vector ListChannels() const; + + // Server-side player mute + void MutePlayer(int playerId, bool muted); + bool IsPlayerMuted(int playerId) const; + + // Throttled voice-activity query: returns true at most once per 300 ms + // per player. Call this before firing the onPlayerVoice Lua event so the + // server console is not flooded at 50 events/sec. + bool ShouldFireVoiceEvent(int playerId); + + // Audio injection: builds and broadcasts an opus frame to channel members. + // Pass kUseChannelPos (NaN) for x/y/z to use the channel's stored position + // instead of an explicit caller-supplied position. + static constexpr float kUseChannelPos = std::numeric_limits::quiet_NaN(); + using UDPSendFunc = std::function&)>; + void SendAudio(int channelId, const std::string& opusData, float x, float y, float z, + const UDPSendFunc& udpSend, + const std::function(int)>& getClient, + float gain = 1.0f); + + // Build a v2 broadcast packet + static std::vector BuildPacket(uint8_t flags, uint16_t sourceId, + const float pos[3], + float maxDistance, + float gain, + const uint8_t* opusData, int opusLen); + +private: + struct Channel { + int id; + std::string name; + std::unordered_set members; + float maxDistance = 0.0f; // 0 = unlimited (within channel) + float position[3] = {0, 0, 0}; // source position for spatial channels + bool spatial = false; // if true, server sets PROXIMITY flag + }; + TVoiceChat() = default; + TVoiceChat(const TVoiceChat&) = delete; + TVoiceChat& operator=(const TVoiceChat&) = delete; + + std::atomic mProximityDistance { 0.0f }; // 0 = unlimited + // Throttle map for onPlayerVoice Lua events — protected by mVoiceEventMutex. + // Intentionally separate from mChannelsMutex to avoid false lock contention + // between channel management and the 50/sec UDP voice hot path. + std::unordered_map mLastVoiceEvent; + mutable std::mutex mVoiceEventMutex; + // mNextChannelId is always read and written inside mChannelsMutex so a plain + // int is correct here. Do NOT make it atomic: incrementing it must be atomic + // with the mChannels insertion, and std::atomic alone would not guarantee that. + int mNextChannelId = 1; + std::unordered_map mChannels; + mutable std::mutex mChannelsMutex; + + std::unordered_set mMutedPlayers; + mutable std::mutex mMuteMutex; +}; diff --git a/src/TLuaEngine.cpp b/src/TLuaEngine.cpp index d7bee3e6..93f9c8fe 100644 --- a/src/TLuaEngine.cpp +++ b/src/TLuaEngine.cpp @@ -25,7 +25,7 @@ #include "Env.h" #include "Profiling.h" #include "TLuaPlugin.h" -#include "sol/object.hpp" +#include "TVoiceChat.h" #include #include @@ -1052,6 +1052,100 @@ TLuaEngine::StateThreadData::StateThreadData(const std::string& Name, TLuaStateI "BestEffort", CallStrategy::BestEffort, "Precise", CallStrategy::Precise); + auto VCTable = MPTable.create_named("VoiceChat"); + VCTable.set_function("SetProximityDistance", [](float dist) { + TVoiceChat::Instance().SetProximityDistance(dist); + }); + VCTable.set_function("GetProximityDistance", []() -> float { + return TVoiceChat::Instance().GetProximityDistance(); + }); + VCTable.set_function("CreateChannel", [](const std::string& name) -> int { + return TVoiceChat::Instance().CreateChannel(name); + }); + VCTable.set_function("DeleteChannel", [](int channelId) -> bool { + return TVoiceChat::Instance().DeleteChannel(channelId); + }); + VCTable.set_function("AddPlayerToChannel", [](int playerId, int channelId) -> bool { + return TVoiceChat::Instance().AddPlayerToChannel(playerId, channelId); + }); + VCTable.set_function("RemovePlayerFromChannel", [](int playerId, int channelId) -> bool { + return TVoiceChat::Instance().RemovePlayerFromChannel(playerId, channelId); + }); + VCTable.set_function("RemovePlayerFromAllChannels", [](int playerId) -> bool { + return TVoiceChat::Instance().RemovePlayerFromAllChannels(playerId); + }); + VCTable.set_function("IsPlayerInChannel", [](int playerId, int channelId) -> bool { + return TVoiceChat::Instance().IsPlayerInChannel(playerId, channelId); + }); + // Query API + VCTable.set_function("GetChannelMembers", [](sol::this_state ts, int channelId) -> sol::table { + sol::state_view sv(ts); + auto members = TVoiceChat::Instance().GetChannelMembers(channelId); + auto tbl = sv.create_table(); + int i = 1; + for (int pid : members) { tbl[i++] = pid; } + return tbl; + }); + VCTable.set_function("GetPlayerChannels", [](sol::this_state ts, int playerId) -> sol::table { + sol::state_view sv(ts); + auto channels = TVoiceChat::Instance().GetPlayerChannels(playerId); + auto tbl = sv.create_table(); + int i = 1; + for (int ch : channels) { tbl[i++] = ch; } + return tbl; + }); + VCTable.set_function("ListChannels", [](sol::this_state ts) -> sol::table { + sol::state_view sv(ts); + auto channels = TVoiceChat::Instance().ListChannels(); + auto tbl = sv.create_table(); + for (const auto& ch : channels) { + auto entry = sv.create_table(); + entry["id"] = ch.id; + entry["name"] = ch.name; + tbl.add(entry); + } + return tbl; + }); + // Channel properties + VCTable.set_function("SetChannelMaxDistance", [](int channelId, float dist) -> bool { + return TVoiceChat::Instance().SetChannelMaxDistance(channelId, dist); + }); + VCTable.set_function("SetChannelPosition", [](int channelId, float x, float y, float z) -> bool { + return TVoiceChat::Instance().SetChannelPosition(channelId, x, y, z); + }); + VCTable.set_function("SetChannelSpatial", [](int channelId, bool spatial) -> bool { + return TVoiceChat::Instance().SetChannelSpatial(channelId, spatial); + }); + // Server-side player mute + VCTable.set_function("MutePlayer", [](int playerId, bool muted) { + TVoiceChat::Instance().MutePlayer(playerId, muted); + }); + VCTable.set_function("IsPlayerMuted", [](int playerId) -> bool { + return TVoiceChat::Instance().IsPlayerMuted(playerId); + }); + // Audio injection - use existing TVoiceChat::SendAudio + VCTable.set_function("SendAudio", [](int channelId, const std::string& opusData, + sol::optional ox, sol::optional oy, sol::optional oz, + sol::optional gainOpt) { + // x/y/z are optional: omit them (or pass nil) to use the channel's stored position. + const float nan = TVoiceChat::kUseChannelPos; + float x = ox.value_or(nan); + float y = oy.value_or(nan); + float z = oz.value_or(nan); + float gain = gainOpt.value_or(1.0f); + TVoiceChat::Instance().SendAudio(channelId, opusData, x, y, z, + [](TClient& client, const std::vector& data) -> void { + (void)LuaAPI::MP::Engine->Network().UDPSend(client, data); + }, + [](int playerId) -> std::shared_ptr { + auto maybeClient = GetClient(LuaAPI::MP::Engine->Server(), playerId); + if (maybeClient && !maybeClient->expired()) { + return maybeClient->lock(); + } + return nullptr; + }, gain); + }); + auto FSTable = StateView.create_named_table("FS"); FSTable.set_function("CreateDirectory", &LuaAPI::FS::CreateDirectory); FSTable.set_function("Exists", &LuaAPI::FS::Exists); @@ -1297,10 +1391,10 @@ void TLuaResult::WaitUntilReady() { this->ReadyCondition->wait(readyLock); } -TLuaChunk::TLuaChunk(std::shared_ptr Content, std::string FileName, std::string PluginPath) - : Content(Content) - , FileName(FileName) - , PluginPath(PluginPath) { +TLuaChunk::TLuaChunk(std::shared_ptr InContent, std::string InFileName, std::string InPluginPath) + : Content(InContent) + , FileName(InFileName) + , PluginPath(InPluginPath) { } bool TLuaEngine::TimedEvent::Expired() { diff --git a/src/TNetwork.cpp b/src/TNetwork.cpp index 9c88dea5..5d374786 100644 --- a/src/TNetwork.cpp +++ b/src/TNetwork.cpp @@ -23,6 +23,7 @@ #include "THeartbeatThread.h" #include "TLuaEngine.h" #include "TScopedTimer.h" +#include "TVoiceChat.h" #include "nlohmann/json.hpp" #include #include @@ -731,6 +732,10 @@ void TNetwork::OnDisconnect(const std::weak_ptr& ClientPtr) { Packet = ("L") + c.GetName() + (" left the server!"); SendToAll(&c, StringToVector(Packet), false, true); Packet.clear(); + // Auto-cleanup voice chat state for disconnecting player + TVoiceChat::Instance().RemovePlayerFromAllChannels(c.GetID()); + TVoiceChat::Instance().MutePlayer(c.GetID(), false); + auto Futures = LuaAPI::MP::Engine->TriggerEvent("onPlayerDisconnect", "", c.GetID()); LuaAPI::MP::Engine->WaitForAll(Futures); c.Disconnect("Already Disconnected (OnDisconnect)"); @@ -1062,7 +1067,8 @@ void TNetwork::SendToAll(TClient* c, const std::vector& Data, bool Self return; } -bool TNetwork::UDPSend(TClient& Client, std::vector Data) { +bool TNetwork::UDPSend(TClient& Client, const std::vector& DataIn) { + std::vector Data = DataIn; // local mutable copy — needed by CompressProperly if (!Client.IsUDPConnected() || Client.IsDisconnected()) { // this can happen if we try to send a packet to a client that is either // 1. not yet fully connected, or diff --git a/src/TServer.cpp b/src/TServer.cpp index 587cef67..b8477f90 100644 --- a/src/TServer.cpp +++ b/src/TServer.cpp @@ -23,6 +23,7 @@ #include "TLuaEngine.h" #include "TNetwork.h" #include "TPPSMonitor.h" +#include "TVoiceChat.h" #include #include #include @@ -287,6 +288,95 @@ void TServer::GlobalParser(const std::weak_ptr& Client, std::vectorGetID(); + + // Server-side mute check + if (VC.IsPlayerMuted(SenderId)) return; + + // Helper: parse a client's active-vehicle position from its JSON blob. + // Returns {0,0,0} when no vehicle or position data is available. + // Prefers unicycle (player avatar); falls back to first vehicle. + // NOTE: parses JSON on every call. A future optimisation is to cache + // a float[3] on TClient whenever a 'Z' position packet arrives. + auto extractPos = [](TClient* client, float out[3]) { + out[0] = out[1] = out[2] = 0.0f; + auto cars = client->GetAllCars(); + if (cars.VehicleData->empty()) return; + int vid = client->GetUnicycleID(); + if (vid < 0) vid = cars.VehicleData->front().ID(); + const std::string raw = client->GetCarPositionRaw(vid); + if (raw.empty()) return; + auto j = nlohmann::json::parse(raw, nullptr, false); + if (!j.is_discarded() && j.contains("pos") + && j["pos"].is_array() && j["pos"].size() >= 3) { + out[0] = j["pos"][0].get(); + out[1] = j["pos"][1].get(); + out[2] = j["pos"][2].get(); + } + }; + + float senderPos[3]; + extractPos(LockedClient.get(), senderPos); + + // Fire voice activity event — throttled to once per 300ms per player + // to avoid flooding the server console and Lua event queue at 50/sec. + if (VC.ShouldFireVoiceEvent(SenderId)) { + LuaAPI::MP::Engine->ReportErrors( + LuaAPI::MP::Engine->TriggerEvent("onPlayerVoice", "", SenderId)); + } + + // Build v2 broadcast packet with PROXIMITY flag + const uint8_t* opusBytes = Packet.data() + 1; + int opusLen = static_cast(Packet.size() - 1); + float proxDist = VC.GetProximityDistance(); + auto Broadcast = TVoiceChat::BuildPacket( + TVoiceChat::FLAG_PROXIMITY, + static_cast(SenderId), + senderPos, proxDist, 1.0f, opusBytes, opusLen); + auto senderChannels = VC.GetPlayerChannels(SenderId); + + // Send to each eligible client + ForEachClient([&](std::weak_ptr ClientPtr) -> bool { + std::shared_ptr Target; + { + ReadLock Lock(GetClientMutex()); + Target = ClientPtr.lock(); + } + if (!Target || Target.get() == LockedClient.get()) return true; + if (!Target->IsSynced()) return true; + + bool shouldSend = false; + // Always send if player shares a channel with sender + if (!senderChannels.empty()) { + auto targetChannels = VC.GetPlayerChannels(Target->GetID()); + for (int ch : senderChannels) { + if (targetChannels.count(ch)) { shouldSend = true; break; } + } + } + // Proximity check (if no channel match yet) + if (!shouldSend) { + if (proxDist <= 0.0f) { + shouldSend = true; // unlimited distance + } else { + float tPos[3]; + extractPos(Target.get(), tPos); + float dx = senderPos[0] - tPos[0]; + float dy = senderPos[1] - tPos[1]; + float dz = senderPos[2] - tPos[2]; + shouldSend = (dx*dx + dy*dy + dz*dz) <= proxDist * proxDist; + } + } + if (shouldSend) { + (void)Network.UDPSend(*Target, Broadcast); + } + return true; + }); + return; + } case 'Z': { // position packet PPSMonitor.IncrementInternalPPS(); diff --git a/src/TVoiceChat.cpp b/src/TVoiceChat.cpp new file mode 100644 index 00000000..c5b30d93 --- /dev/null +++ b/src/TVoiceChat.cpp @@ -0,0 +1,235 @@ +// BeamMP, the BeamNG.drive multiplayer mod. +// Copyright (C) 2024 BeamMP Ltd., BeamMP team and contributors. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +#include "TVoiceChat.h" +#include +#include + +namespace { + // Explicit little-endian helpers — portable to any architecture. + // Wire format: little-endian IEEE 754 floats, little-endian uint32. + inline void pushLE32(std::vector& buf, uint32_t v) { + buf.push_back(static_cast( v & 0xFF)); + buf.push_back(static_cast((v >> 8) & 0xFF)); + buf.push_back(static_cast((v >> 16) & 0xFF)); + buf.push_back(static_cast((v >> 24) & 0xFF)); + } + inline void pushLEFloat(std::vector& buf, float f) { + uint32_t bits; std::memcpy(&bits, &f, sizeof(float)); + pushLE32(buf, bits); + } +} // namespace + +TVoiceChat& TVoiceChat::Instance() { + static TVoiceChat instance; + return instance; +} + +void TVoiceChat::SetProximityDistance(float distance) { + mProximityDistance.store(distance < 0.0f ? 0.0f : distance); +} + +float TVoiceChat::GetProximityDistance() const { + return mProximityDistance.load(); +} + +int TVoiceChat::CreateChannel(const std::string& name) { + std::lock_guard lock(mChannelsMutex); + int id = mNextChannelId++; + Channel ch; + ch.id = id; + ch.name = name; + ch.maxDistance = 0.0f; + ch.position[0] = ch.position[1] = ch.position[2] = 0.0f; + ch.spatial = false; + mChannels[id] = std::move(ch); + return id; +} + +bool TVoiceChat::DeleteChannel(int channelId) { + std::lock_guard lock(mChannelsMutex); + return mChannels.erase(channelId) > 0; +} + +bool TVoiceChat::AddPlayerToChannel(int playerId, int channelId) { + std::lock_guard lock(mChannelsMutex); + auto it = mChannels.find(channelId); + if (it == mChannels.end()) return false; + it->second.members.insert(playerId); + return true; +} + +bool TVoiceChat::RemovePlayerFromChannel(int playerId, int channelId) { + std::lock_guard lock(mChannelsMutex); + auto it = mChannels.find(channelId); + if (it == mChannels.end()) return false; + return it->second.members.erase(playerId) > 0; +} + +bool TVoiceChat::RemovePlayerFromAllChannels(int playerId) { + std::lock_guard lock(mChannelsMutex); + bool removed = false; + for (auto& [id, ch] : mChannels) { + if (ch.members.erase(playerId) > 0) removed = true; + } + return removed; +} + +std::unordered_set TVoiceChat::GetChannelMembers(int channelId) const { + std::lock_guard lock(mChannelsMutex); + auto it = mChannels.find(channelId); + if (it == mChannels.end()) return {}; + return it->second.members; +} + +std::unordered_set TVoiceChat::GetPlayerChannels(int playerId) const { + std::lock_guard lock(mChannelsMutex); + std::unordered_set result; + for (const auto& [id, ch] : mChannels) { + if (ch.members.count(playerId)) result.insert(id); + } + return result; +} + +bool TVoiceChat::IsPlayerInChannel(int playerId, int channelId) const { + std::lock_guard lock(mChannelsMutex); + auto it = mChannels.find(channelId); + if (it == mChannels.end()) return false; + return it->second.members.count(playerId) > 0; +} + +// ── channel properties ────────────────────────────────── + +bool TVoiceChat::SetChannelMaxDistance(int channelId, float distance) { + std::lock_guard lock(mChannelsMutex); + auto it = mChannels.find(channelId); + if (it == mChannels.end()) return false; + it->second.maxDistance = distance < 0.0f ? 0.0f : distance; + return true; +} + +bool TVoiceChat::SetChannelPosition(int channelId, float x, float y, float z) { + std::lock_guard lock(mChannelsMutex); + auto it = mChannels.find(channelId); + if (it == mChannels.end()) return false; + it->second.position[0] = x; + it->second.position[1] = y; + it->second.position[2] = z; + return true; +} + +bool TVoiceChat::SetChannelSpatial(int channelId, bool spatial) { + std::lock_guard lock(mChannelsMutex); + auto it = mChannels.find(channelId); + if (it == mChannels.end()) return false; + it->second.spatial = spatial; + return true; +} + +std::vector TVoiceChat::ListChannels() const { + std::lock_guard lock(mChannelsMutex); + std::vector result; + result.reserve(mChannels.size()); + for (const auto& [id, ch] : mChannels) { + result.push_back({ ch.id, ch.name }); + } + return result; +} + +// ── player mute ───────────────────────────────────────── + +void TVoiceChat::MutePlayer(int playerId, bool muted) { + std::lock_guard lock(mMuteMutex); + if (muted) { + mMutedPlayers.insert(playerId); + } else { + mMutedPlayers.erase(playerId); + } +} + +bool TVoiceChat::ShouldFireVoiceEvent(int playerId) { + using namespace std::chrono; + auto now = steady_clock::now(); + std::lock_guard lock(mVoiceEventMutex); + auto& last = mLastVoiceEvent[playerId]; + if (duration_cast(now - last).count() >= 300) { + last = now; + return true; + } + return false; +} + +bool TVoiceChat::IsPlayerMuted(int playerId) const { + std::lock_guard lock(mMuteMutex); + return mMutedPlayers.count(playerId) > 0; +} + +// ── packet building ───────────────────────────────────── + +std::vector TVoiceChat::BuildPacket(uint8_t flags, uint16_t sourceId, + const float pos[3], + float maxDistance, + float gain, + const uint8_t* opusData, int opusLen) { + std::vector pkt; + pkt.reserve(HEADER_SIZE + opusLen); + pkt.push_back('F'); + pkt.push_back(PROTOCOL_VERSION); + pkt.push_back(flags); + pkt.push_back(static_cast(sourceId & 0xFF)); + pkt.push_back(static_cast((sourceId >> 8) & 0xFF)); + pushLEFloat(pkt, pos[0]); + pushLEFloat(pkt, pos[1]); + pushLEFloat(pkt, pos[2]); + pushLEFloat(pkt, maxDistance); + pushLEFloat(pkt, gain); + pkt.insert(pkt.end(), opusData, opusData + opusLen); + return pkt; +} + +// ── audio injection ───────────────────────────────────── + +void TVoiceChat::SendAudio(int channelId, const std::string& opusData, float x, float y, float z, + const UDPSendFunc& udpSend, + const std::function(int)>& getClient, + float gain) { + std::unordered_set members; + float chPos[3] = { x, y, z }; + float maxDist = 0.0f; + bool spatial = false; + { + std::lock_guard lock(mChannelsMutex); + auto it = mChannels.find(channelId); + if (it == mChannels.end()) return; + members = it->second.members; + // Use channel position when caller passes kUseChannelPos (NaN) sentinel + if (std::isnan(x) || std::isnan(y) || std::isnan(z)) { + chPos[0] = it->second.position[0]; + chPos[1] = it->second.position[1]; + chPos[2] = it->second.position[2]; + } + maxDist = it->second.maxDistance; + spatial = it->second.spatial; + } + + uint8_t flags = FLAG_INJECTED; + if (spatial) flags |= FLAG_PROXIMITY; + + auto pkt = BuildPacket(flags, static_cast(channelId), + chPos, + maxDist, + gain, + reinterpret_cast(opusData.data()), + static_cast(opusData.size())); + + for (int pid : members) { + auto client = getClient(pid); + if (!client) continue; + udpSend(*client, pkt); + } +} From 6357a93a0f24b6a86a32cdaab90862a1a385d01c Mon Sep 17 00:00:00 2001 From: Adriel Guerrero Date: Sun, 12 Apr 2026 21:09:42 -0700 Subject: [PATCH 2/4] #485 Included memory for resource management --- include/TVoiceChat.h | 1 + 1 file changed, 1 insertion(+) diff --git a/include/TVoiceChat.h b/include/TVoiceChat.h index 90165836..ce05f925 100644 --- a/include/TVoiceChat.h +++ b/include/TVoiceChat.h @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include From 8f9c2d50a72734144bc80bcbe8f961a01d6591f5 Mon Sep 17 00:00:00 2001 From: Adriel Guerrero Date: Sun, 12 Apr 2026 21:20:58 -0700 Subject: [PATCH 3/4] #485 Added CleanupPlayer method --- include/TVoiceChat.h | 4 ++++ src/TNetwork.cpp | 3 +-- src/TVoiceChat.cpp | 12 ++++++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/include/TVoiceChat.h b/include/TVoiceChat.h index ce05f925..3c811209 100644 --- a/include/TVoiceChat.h +++ b/include/TVoiceChat.h @@ -74,6 +74,10 @@ class TVoiceChat { void MutePlayer(int playerId, bool muted); bool IsPlayerMuted(int playerId) const; + // Removes all per-player state (channels, mute, throttle). + // Must be called when a player disconnects. + void CleanupPlayer(int playerId); + // Throttled voice-activity query: returns true at most once per 300 ms // per player. Call this before firing the onPlayerVoice Lua event so the // server console is not flooded at 50 events/sec. diff --git a/src/TNetwork.cpp b/src/TNetwork.cpp index e6b2d1c3..03fdc30f 100644 --- a/src/TNetwork.cpp +++ b/src/TNetwork.cpp @@ -825,8 +825,7 @@ void TNetwork::OnDisconnect(const std::weak_ptr& ClientPtr) { SendToAll(&c, StringToVector(Packet), false, true); Packet.clear(); // Auto-cleanup voice chat state for disconnecting player - TVoiceChat::Instance().RemovePlayerFromAllChannels(c.GetID()); - TVoiceChat::Instance().MutePlayer(c.GetID(), false); + TVoiceChat::Instance().CleanupPlayer(c.GetID()); auto Futures = LuaAPI::MP::Engine->TriggerEvent("onPlayerDisconnect", "", c.GetID()); LuaAPI::MP::Engine->WaitForAll(Futures); diff --git a/src/TVoiceChat.cpp b/src/TVoiceChat.cpp index c5b30d93..e9fc5a29 100644 --- a/src/TVoiceChat.cpp +++ b/src/TVoiceChat.cpp @@ -169,6 +169,18 @@ bool TVoiceChat::IsPlayerMuted(int playerId) const { return mMutedPlayers.count(playerId) > 0; } +void TVoiceChat::CleanupPlayer(int playerId) { + RemovePlayerFromAllChannels(playerId); + { + std::lock_guard lock(mMuteMutex); + mMutedPlayers.erase(playerId); + } + { + std::lock_guard lock(mVoiceEventMutex); + mLastVoiceEvent.erase(playerId); + } +} + // ── packet building ───────────────────────────────────── std::vector TVoiceChat::BuildPacket(uint8_t flags, uint16_t sourceId, From a267dbbd15669c805d9b3f4262a073490f32fbce Mon Sep 17 00:00:00 2001 From: Adriel Guerrero Date: Sun, 12 Apr 2026 21:47:07 -0700 Subject: [PATCH 4/4] #485 Remove singleton, make TServer owner TVoiceChat was a Meyers singleton accessed via Instance() from 22 call sites across TServer, TNetwork, and TLuaEngine. This change: - Makes TVoiceChat a regular member of TServer (consistent with how TNetwork owns TResourceManager) - Removes Instance() and makes ctor public - Updates all callers to use Server.VoiceChat() or captured ptr --- include/TServer.h | 5 +++ include/TVoiceChat.h | 7 ++--- src/TLuaEngine.cpp | 74 +++++++++++++++++++++++--------------------- src/TNetwork.cpp | 2 +- src/TServer.cpp | 2 +- src/TVoiceChat.cpp | 5 --- 6 files changed, 49 insertions(+), 46 deletions(-) diff --git a/include/TServer.h b/include/TServer.h index 4ee9f969..ab811f12 100644 --- a/include/TServer.h +++ b/include/TServer.h @@ -27,6 +27,7 @@ #include #include "BoostAliases.h" +#include "TVoiceChat.h" class TClient; class TNetwork; @@ -50,10 +51,14 @@ class TServer final { const TScopedTimer UptimeTimer; + TVoiceChat& VoiceChat() { return mVoiceChat; } + const TVoiceChat& VoiceChat() const { return mVoiceChat; } + // asio io context io_context& IoCtx() { return mIoCtx; } private: + TVoiceChat mVoiceChat; io_context mIoCtx {}; TClientSet mClients; mutable RWMutex mClientsMutex; diff --git a/include/TVoiceChat.h b/include/TVoiceChat.h index 3c811209..4c49030a 100644 --- a/include/TVoiceChat.h +++ b/include/TVoiceChat.h @@ -26,7 +26,9 @@ class TClient; class TVoiceChat { public: - static TVoiceChat& Instance(); + TVoiceChat() = default; + TVoiceChat(const TVoiceChat&) = delete; + TVoiceChat& operator=(const TVoiceChat&) = delete; // Packet v2 constants (must match Launcher VoiceChat.h exactly). // Wire layout (25 bytes, little-endian): @@ -109,9 +111,6 @@ class TVoiceChat { float position[3] = {0, 0, 0}; // source position for spatial channels bool spatial = false; // if true, server sets PROXIMITY flag }; - TVoiceChat() = default; - TVoiceChat(const TVoiceChat&) = delete; - TVoiceChat& operator=(const TVoiceChat&) = delete; std::atomic mProximityDistance { 0.0f }; // 0 = unlimited // Throttle map for onPlayerVoice Lua events — protected by mVoiceEventMutex. diff --git a/src/TLuaEngine.cpp b/src/TLuaEngine.cpp index 93f9c8fe..c8665fd8 100644 --- a/src/TLuaEngine.cpp +++ b/src/TLuaEngine.cpp @@ -1052,51 +1052,55 @@ TLuaEngine::StateThreadData::StateThreadData(const std::string& Name, TLuaStateI "BestEffort", CallStrategy::BestEffort, "Precise", CallStrategy::Precise); + // Voice chat Lua bindings. + // Capture a raw pointer to the TServer-owned TVoiceChat instance. + // Lifetime is safe: TServer outlives all Lua states. + TVoiceChat* vc = &mEngine->Server().VoiceChat(); auto VCTable = MPTable.create_named("VoiceChat"); - VCTable.set_function("SetProximityDistance", [](float dist) { - TVoiceChat::Instance().SetProximityDistance(dist); + VCTable.set_function("SetProximityDistance", [vc](float dist) { + vc->SetProximityDistance(dist); }); - VCTable.set_function("GetProximityDistance", []() -> float { - return TVoiceChat::Instance().GetProximityDistance(); + VCTable.set_function("GetProximityDistance", [vc]() -> float { + return vc->GetProximityDistance(); }); - VCTable.set_function("CreateChannel", [](const std::string& name) -> int { - return TVoiceChat::Instance().CreateChannel(name); + VCTable.set_function("CreateChannel", [vc](const std::string& name) -> int { + return vc->CreateChannel(name); }); - VCTable.set_function("DeleteChannel", [](int channelId) -> bool { - return TVoiceChat::Instance().DeleteChannel(channelId); + VCTable.set_function("DeleteChannel", [vc](int channelId) -> bool { + return vc->DeleteChannel(channelId); }); - VCTable.set_function("AddPlayerToChannel", [](int playerId, int channelId) -> bool { - return TVoiceChat::Instance().AddPlayerToChannel(playerId, channelId); + VCTable.set_function("AddPlayerToChannel", [vc](int playerId, int channelId) -> bool { + return vc->AddPlayerToChannel(playerId, channelId); }); - VCTable.set_function("RemovePlayerFromChannel", [](int playerId, int channelId) -> bool { - return TVoiceChat::Instance().RemovePlayerFromChannel(playerId, channelId); + VCTable.set_function("RemovePlayerFromChannel", [vc](int playerId, int channelId) -> bool { + return vc->RemovePlayerFromChannel(playerId, channelId); }); - VCTable.set_function("RemovePlayerFromAllChannels", [](int playerId) -> bool { - return TVoiceChat::Instance().RemovePlayerFromAllChannels(playerId); + VCTable.set_function("RemovePlayerFromAllChannels", [vc](int playerId) -> bool { + return vc->RemovePlayerFromAllChannels(playerId); }); - VCTable.set_function("IsPlayerInChannel", [](int playerId, int channelId) -> bool { - return TVoiceChat::Instance().IsPlayerInChannel(playerId, channelId); + VCTable.set_function("IsPlayerInChannel", [vc](int playerId, int channelId) -> bool { + return vc->IsPlayerInChannel(playerId, channelId); }); // Query API - VCTable.set_function("GetChannelMembers", [](sol::this_state ts, int channelId) -> sol::table { + VCTable.set_function("GetChannelMembers", [vc](sol::this_state ts, int channelId) -> sol::table { sol::state_view sv(ts); - auto members = TVoiceChat::Instance().GetChannelMembers(channelId); + auto members = vc->GetChannelMembers(channelId); auto tbl = sv.create_table(); int i = 1; for (int pid : members) { tbl[i++] = pid; } return tbl; }); - VCTable.set_function("GetPlayerChannels", [](sol::this_state ts, int playerId) -> sol::table { + VCTable.set_function("GetPlayerChannels", [vc](sol::this_state ts, int playerId) -> sol::table { sol::state_view sv(ts); - auto channels = TVoiceChat::Instance().GetPlayerChannels(playerId); + auto channels = vc->GetPlayerChannels(playerId); auto tbl = sv.create_table(); int i = 1; for (int ch : channels) { tbl[i++] = ch; } return tbl; }); - VCTable.set_function("ListChannels", [](sol::this_state ts) -> sol::table { + VCTable.set_function("ListChannels", [vc](sol::this_state ts) -> sol::table { sol::state_view sv(ts); - auto channels = TVoiceChat::Instance().ListChannels(); + auto channels = vc->ListChannels(); auto tbl = sv.create_table(); for (const auto& ch : channels) { auto entry = sv.create_table(); @@ -1107,24 +1111,24 @@ TLuaEngine::StateThreadData::StateThreadData(const std::string& Name, TLuaStateI return tbl; }); // Channel properties - VCTable.set_function("SetChannelMaxDistance", [](int channelId, float dist) -> bool { - return TVoiceChat::Instance().SetChannelMaxDistance(channelId, dist); + VCTable.set_function("SetChannelMaxDistance", [vc](int channelId, float dist) -> bool { + return vc->SetChannelMaxDistance(channelId, dist); }); - VCTable.set_function("SetChannelPosition", [](int channelId, float x, float y, float z) -> bool { - return TVoiceChat::Instance().SetChannelPosition(channelId, x, y, z); + VCTable.set_function("SetChannelPosition", [vc](int channelId, float x, float y, float z) -> bool { + return vc->SetChannelPosition(channelId, x, y, z); }); - VCTable.set_function("SetChannelSpatial", [](int channelId, bool spatial) -> bool { - return TVoiceChat::Instance().SetChannelSpatial(channelId, spatial); + VCTable.set_function("SetChannelSpatial", [vc](int channelId, bool spatial) -> bool { + return vc->SetChannelSpatial(channelId, spatial); }); // Server-side player mute - VCTable.set_function("MutePlayer", [](int playerId, bool muted) { - TVoiceChat::Instance().MutePlayer(playerId, muted); + VCTable.set_function("MutePlayer", [vc](int playerId, bool muted) { + vc->MutePlayer(playerId, muted); }); - VCTable.set_function("IsPlayerMuted", [](int playerId) -> bool { - return TVoiceChat::Instance().IsPlayerMuted(playerId); + VCTable.set_function("IsPlayerMuted", [vc](int playerId) -> bool { + return vc->IsPlayerMuted(playerId); }); - // Audio injection - use existing TVoiceChat::SendAudio - VCTable.set_function("SendAudio", [](int channelId, const std::string& opusData, + // Audio injection + VCTable.set_function("SendAudio", [vc](int channelId, const std::string& opusData, sol::optional ox, sol::optional oy, sol::optional oz, sol::optional gainOpt) { // x/y/z are optional: omit them (or pass nil) to use the channel's stored position. @@ -1133,7 +1137,7 @@ TLuaEngine::StateThreadData::StateThreadData(const std::string& Name, TLuaStateI float y = oy.value_or(nan); float z = oz.value_or(nan); float gain = gainOpt.value_or(1.0f); - TVoiceChat::Instance().SendAudio(channelId, opusData, x, y, z, + vc->SendAudio(channelId, opusData, x, y, z, [](TClient& client, const std::vector& data) -> void { (void)LuaAPI::MP::Engine->Network().UDPSend(client, data); }, diff --git a/src/TNetwork.cpp b/src/TNetwork.cpp index 03fdc30f..03e96610 100644 --- a/src/TNetwork.cpp +++ b/src/TNetwork.cpp @@ -825,7 +825,7 @@ void TNetwork::OnDisconnect(const std::weak_ptr& ClientPtr) { SendToAll(&c, StringToVector(Packet), false, true); Packet.clear(); // Auto-cleanup voice chat state for disconnecting player - TVoiceChat::Instance().CleanupPlayer(c.GetID()); + mServer.VoiceChat().CleanupPlayer(c.GetID()); auto Futures = LuaAPI::MP::Engine->TriggerEvent("onPlayerDisconnect", "", c.GetID()); LuaAPI::MP::Engine->WaitForAll(Futures); diff --git a/src/TServer.cpp b/src/TServer.cpp index b8477f90..9cca7efe 100644 --- a/src/TServer.cpp +++ b/src/TServer.cpp @@ -291,7 +291,7 @@ void TServer::GlobalParser(const std::weak_ptr& Client, std::vectorGetID(); // Server-side mute check diff --git a/src/TVoiceChat.cpp b/src/TVoiceChat.cpp index e9fc5a29..db06bcac 100644 --- a/src/TVoiceChat.cpp +++ b/src/TVoiceChat.cpp @@ -25,11 +25,6 @@ namespace { } } // namespace -TVoiceChat& TVoiceChat::Instance() { - static TVoiceChat instance; - return instance; -} - void TVoiceChat::SetProximityDistance(float distance) { mProximityDistance.store(distance < 0.0f ? 0.0f : distance); }