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 d5e54731..121446e1 100644 --- a/include/TNetwork.h +++ b/include/TNetwork.h @@ -43,7 +43,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); boost::system::error_code ReadWithTimeout(TConnection& Connection, void* Buf, size_t Len, std::chrono::steady_clock::duration Timeout); 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 new file mode 100644 index 00000000..4c49030a --- /dev/null +++ b/include/TVoiceChat.h @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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; + + // 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::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 + }; + + 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..c8665fd8 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,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 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); + vc->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 +1395,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 d0393e89..03e96610 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 @@ -823,6 +824,9 @@ 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 + mServer.VoiceChat().CleanupPlayer(c.GetID()); + auto Futures = LuaAPI::MP::Engine->TriggerEvent("onPlayerDisconnect", "", c.GetID()); LuaAPI::MP::Engine->WaitForAll(Futures); DisconnectClient(c, "Already Disconnected (OnDisconnect)"); @@ -1154,7 +1158,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..9cca7efe 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..db06bcac --- /dev/null +++ b/src/TVoiceChat.cpp @@ -0,0 +1,242 @@ +// 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 + +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; +} + +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, + 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); + } +}