Skip to content
Draft
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: 2 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion include/TNetwork.h
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class TNetwork {
void Identify(TConnection&& client);
std::shared_ptr<TClient> Authentication(TConnection&& ClientConnection);
void SyncResources(TClient& c);
[[nodiscard]] bool UDPSend(TClient& Client, std::vector<uint8_t> Data);
[[nodiscard]] bool UDPSend(TClient& Client, const std::vector<uint8_t>& Data);
void SendToAll(TClient* c, const std::vector<uint8_t>& Data, bool Self, bool Rel);
void UpdatePlayer(TClient& Client);
boost::system::error_code ReadWithTimeout(TConnection& Connection, void* Buf, size_t Len, std::chrono::steady_clock::duration Timeout);
Expand Down
5 changes: 5 additions & 0 deletions include/TServer.h
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
#include <unordered_set>

#include "BoostAliases.h"
#include "TVoiceChat.h"

class TClient;
class TNetwork;
Expand All @@ -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;
Expand Down
130 changes: 130 additions & 0 deletions include/TVoiceChat.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// 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 <atomic>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <functional>
#include <limits>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
#include <unordered_map>
#include <unordered_set>

class TNetwork;
class TClient;

class TVoiceChat {
public:
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):
// [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<int> GetChannelMembers(int channelId) const;
std::unordered_set<int> 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<ChannelInfo> ListChannels() const;

// Server-side player mute
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.
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<float>::quiet_NaN();
using UDPSendFunc = std::function<void(TClient&, const std::vector<uint8_t>&)>;
void SendAudio(int channelId, const std::string& opusData, float x, float y, float z,
const UDPSendFunc& udpSend,
const std::function<std::shared_ptr<TClient>(int)>& getClient,
float gain = 1.0f);

// Build a v2 broadcast packet
static std::vector<uint8_t> 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<int> 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
};

std::atomic<float> 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<int, std::chrono::steady_clock::time_point> 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<int, Channel> mChannels;
mutable std::mutex mChannelsMutex;

std::unordered_set<int> mMutedPlayers;
mutable std::mutex mMuteMutex;
};
108 changes: 103 additions & 5 deletions src/TLuaEngine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
#include "Env.h"
#include "Profiling.h"
#include "TLuaPlugin.h"
#include "sol/object.hpp"
#include "TVoiceChat.h"

#include <chrono>
#include <condition_variable>
Expand Down Expand Up @@ -1052,6 +1052,104 @@ 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", [vc](float dist) {
vc->SetProximityDistance(dist);
});
VCTable.set_function("GetProximityDistance", [vc]() -> float {
return vc->GetProximityDistance();
});
VCTable.set_function("CreateChannel", [vc](const std::string& name) -> int {
return vc->CreateChannel(name);
});
VCTable.set_function("DeleteChannel", [vc](int channelId) -> bool {
return vc->DeleteChannel(channelId);
});
VCTable.set_function("AddPlayerToChannel", [vc](int playerId, int channelId) -> bool {
return vc->AddPlayerToChannel(playerId, channelId);
});
VCTable.set_function("RemovePlayerFromChannel", [vc](int playerId, int channelId) -> bool {
return vc->RemovePlayerFromChannel(playerId, channelId);
});
VCTable.set_function("RemovePlayerFromAllChannels", [vc](int playerId) -> bool {
return vc->RemovePlayerFromAllChannels(playerId);
});
VCTable.set_function("IsPlayerInChannel", [vc](int playerId, int channelId) -> bool {
return vc->IsPlayerInChannel(playerId, channelId);
});
// Query API
VCTable.set_function("GetChannelMembers", [vc](sol::this_state ts, int channelId) -> sol::table {
sol::state_view sv(ts);
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", [vc](sol::this_state ts, int playerId) -> sol::table {
sol::state_view sv(ts);
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", [vc](sol::this_state ts) -> sol::table {
sol::state_view sv(ts);
auto channels = vc->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", [vc](int channelId, float dist) -> bool {
return vc->SetChannelMaxDistance(channelId, dist);
});
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", [vc](int channelId, bool spatial) -> bool {
return vc->SetChannelSpatial(channelId, spatial);
});
// Server-side player mute
VCTable.set_function("MutePlayer", [vc](int playerId, bool muted) {
vc->MutePlayer(playerId, muted);
});
VCTable.set_function("IsPlayerMuted", [vc](int playerId) -> bool {
return vc->IsPlayerMuted(playerId);
});
// Audio injection
VCTable.set_function("SendAudio", [vc](int channelId, const std::string& opusData,
sol::optional<float> ox, sol::optional<float> oy, sol::optional<float> oz,
sol::optional<float> 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);
vc->SendAudio(channelId, opusData, x, y, z,
[](TClient& client, const std::vector<uint8_t>& data) -> void {
(void)LuaAPI::MP::Engine->Network().UDPSend(client, data);
},
[](int playerId) -> std::shared_ptr<TClient> {
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);
Expand Down Expand Up @@ -1297,10 +1395,10 @@ void TLuaResult::WaitUntilReady() {
this->ReadyCondition->wait(readyLock);
}

TLuaChunk::TLuaChunk(std::shared_ptr<std::string> Content, std::string FileName, std::string PluginPath)
: Content(Content)
, FileName(FileName)
, PluginPath(PluginPath) {
TLuaChunk::TLuaChunk(std::shared_ptr<std::string> InContent, std::string InFileName, std::string InPluginPath)
: Content(InContent)
, FileName(InFileName)
, PluginPath(InPluginPath) {
}

bool TLuaEngine::TimedEvent::Expired() {
Expand Down
7 changes: 6 additions & 1 deletion src/TNetwork.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
#include "THeartbeatThread.h"
#include "TLuaEngine.h"
#include "TScopedTimer.h"
#include "TVoiceChat.h"
#include "nlohmann/json.hpp"
#include <CustomAssert.h>
#include <Http.h>
Expand Down Expand Up @@ -823,6 +824,9 @@ void TNetwork::OnDisconnect(const std::weak_ptr<TClient>& ClientPtr) {
Packet = ("L") + c.GetName() + (" left the server!");
SendToAll(&c, StringToVector(Packet), false, true);
Packet.clear();
// Auto-cleanup voice chat state for disconnecting player
mServer.VoiceChat().CleanupPlayer(c.GetID());

auto Futures = LuaAPI::MP::Engine->TriggerEvent("onPlayerDisconnect", "", c.GetID());
LuaAPI::MP::Engine->WaitForAll(Futures);
DisconnectClient(c, "Already Disconnected (OnDisconnect)");
Expand Down Expand Up @@ -1154,7 +1158,8 @@ void TNetwork::SendToAll(TClient* c, const std::vector<uint8_t>& Data, bool Self
return;
}

bool TNetwork::UDPSend(TClient& Client, std::vector<uint8_t> Data) {
bool TNetwork::UDPSend(TClient& Client, const std::vector<uint8_t>& DataIn) {
std::vector<uint8_t> 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
Expand Down
Loading