Skip to content

Commit 3e2dd37

Browse files
committed
feat: Add lobby/in-game voice chat (Opus + WASAPI) with persistent per-peer mutes
1 parent 6266009 commit 3e2dd37

21 files changed

Lines changed: 5012 additions & 1261 deletions

GeneralsMD/Code/GameEngine/CMakeLists.txt

Lines changed: 1289 additions & 1251 deletions
Large diffs are not rendered by default.

GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
#pragma once
22
#include "libcurl/curl.h"
3+
#include <string>
4+
#include <vector>
5+
#include <cstdint>
36

47
enum EHTTPVersion
58
{
@@ -61,6 +64,69 @@ class GenOnlineSettings
6164

6265
bool Debug_VerboseLogging() const { return m_bVerbose; }
6366

67+
// -------- Lobby voice chat settings --------
68+
bool Voice_GetEnabled() const { return m_Voice_Enabled; }
69+
const std::wstring& Voice_GetCaptureDeviceID() const { return m_Voice_CaptureDeviceID; }
70+
float Voice_GetMicGain() const { return m_Voice_MicGain; }
71+
float Voice_GetGlobalVolume() const { return m_Voice_GlobalVolume; }
72+
73+
// -------- Persistent per-client voice ignore list --------
74+
// Client-local only. Never transmitted, never uploaded. When the local
75+
// user mutes a peer via /voice mute, that peer's NGMP userID gets
76+
// appended here and the list survives game restarts, so a troll that
77+
// constantly leaves and re-joins the same lobby cannot bypass the mute
78+
// just by reconnecting.
79+
//
80+
// Muting affects ONLY the muter's own playback: VoicePlayback drops the
81+
// decoded audio locally. Nobody else's client knows or cares.
82+
const std::vector<int64_t>& Voice_GetMutedPeers() const { return m_Voice_MutedPeers; }
83+
84+
void Save_Voice_Enabled(bool enabled)
85+
{
86+
m_Voice_Enabled = enabled;
87+
Save();
88+
}
89+
void Save_Voice_CaptureDeviceID(const std::wstring& deviceID)
90+
{
91+
m_Voice_CaptureDeviceID = deviceID;
92+
Save();
93+
}
94+
void Save_Voice_MicGain(float gain)
95+
{
96+
if (gain < 0.0f) gain = 0.0f;
97+
if (gain > 4.0f) gain = 4.0f;
98+
m_Voice_MicGain = gain;
99+
Save();
100+
}
101+
void Save_Voice_GlobalVolume(float volume)
102+
{
103+
if (volume < 0.0f) volume = 0.0f;
104+
if (volume > 2.0f) volume = 2.0f;
105+
m_Voice_GlobalVolume = volume;
106+
Save();
107+
}
108+
109+
// Replace the full persistent mute list. Caller is expected to dedupe
110+
// and drop invalid IDs (<=0) beforehand; we still re-validate here so
111+
// a corrupt caller cannot poison the file.
112+
void Save_Voice_MutedPeers(const std::vector<int64_t>& mutedPeers)
113+
{
114+
m_Voice_MutedPeers.clear();
115+
m_Voice_MutedPeers.reserve(mutedPeers.size());
116+
for (int64_t id : mutedPeers)
117+
{
118+
if (id <= 0) continue;
119+
// dedupe - small N, linear scan is cheaper than a set
120+
bool dup = false;
121+
for (int64_t existing : m_Voice_MutedPeers)
122+
{
123+
if (existing == id) { dup = true; break; }
124+
}
125+
if (!dup) m_Voice_MutedPeers.push_back(id);
126+
}
127+
Save();
128+
}
129+
64130
int GetChatLifeSeconds() const { return std::max<int>(m_Chat_LifeSeconds, 10); }
65131

66132
void Initialize()
@@ -138,4 +204,17 @@ class GenOnlineSettings
138204

139205
EHTTPVersion m_Network_HTTPVersion = EHTTPVersion::HTTP_VERSION_AUTO;
140206
bool m_Network_UseAlternativeEndpoint = false;
207+
208+
// -------- Lobby voice chat settings --------
209+
// Empty string = use the system default communications device.
210+
bool m_Voice_Enabled = true;
211+
std::wstring m_Voice_CaptureDeviceID;
212+
float m_Voice_MicGain = 1.0f;
213+
float m_Voice_GlobalVolume = 1.0f;
214+
215+
// Persistent per-client ignore list. See comment on Voice_GetMutedPeers.
216+
// Stored in the settings JSON as an array of DECIMAL STRINGS (not raw
217+
// numbers) because NGMP user IDs can exceed the ~2^53 safe integer
218+
// precision of nlohmann::json's default number handling.
219+
std::vector<int64_t> m_Voice_MutedPeers;
141220
};
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// NGMPVoiceBridge.h
2+
//
3+
// Glue between the VoiceManager (platform-agnostic audio + codec) and
4+
// the NGMP peer mesh. Lives here so Voice/ stays free of NGMP/Steam
5+
// includes and NGMP/ stays free of WASAPI/Opus includes.
6+
//
7+
// The bridge owns:
8+
// - the global TheVoiceManager lifetime (create in Init, destroy in Shutdown)
9+
// - the PacketSink that serialises a voice frame onto the wire with the
10+
// VOICE_MAGIC_NUMBER prefix and broadcasts it to every peer in the mesh
11+
// - the reverse path: given a SteamNetworkingMessage payload whose
12+
// TransportMessageHeader magic == VOICE_MAGIC_NUMBER, strip the header
13+
// and hand the remaining bytes to TheVoiceManager->OnVoicePacket
14+
// - lobby -> in-game state transitions driven by the lobby lifecycle
15+
// - the per-frame PTT poll (Left Alt via GetAsyncKeyState)
16+
//
17+
// Called from:
18+
// - GameEngine::init -> NGMPVoice_Init
19+
// - GameEngine::~GameEngine -> NGMPVoice_Shutdown
20+
// - GameEngine::update -> NGMPVoice_Update
21+
// - OnlineServices_LobbyInterface (Join/Create success) -> NGMPVoice_OnEnteredLobby
22+
// - OnlineServices_LobbyInterface::LeaveCurrentLobby -> NGMPVoice_OnLeftLobby
23+
// - NextGenTransport::doRecv (on magic == VOICE_MAGIC_NUMBER) ->
24+
// NGMPVoice_DispatchIncoming
25+
26+
#pragma once
27+
28+
#include <cstdint>
29+
30+
class NGMPVoiceBridge
31+
{
32+
public:
33+
// Called once, very early in GameEngine::init. Safe to call without
34+
// ENABLE_VOICE_CHAT (becomes a no-op).
35+
static void Init();
36+
37+
// Called once, in GameEngine::~GameEngine. No-op if Init wasn't called.
38+
static void Shutdown();
39+
40+
// Called every frame from GameEngine::update. Polls the PTT key and
41+
// performs the lobby -> in-game mode transition.
42+
static void Update();
43+
44+
// Called by the NGMP lobby interface after it successfully joins or
45+
// creates a lobby (i.e. after m_pLobbyMesh has been constructed). The
46+
// bridge then asks the VoiceManager to open the mic/speakers and
47+
// installs the broadcast sink.
48+
static void OnEnteredLobby(int64_t myUserID);
49+
50+
// Called when leaving the current lobby, before the mesh is deleted.
51+
// Closes audio devices and clears the sink pointer.
52+
static void OnLeftLobby();
53+
54+
// Dispatch a raw incoming network message that has already been
55+
// identified as a voice packet by its header magic. `payload` must
56+
// point to the bytes that follow the 6-byte TransportMessageHeader,
57+
// and `payloadLen` must be the number of such bytes.
58+
static void DispatchIncoming(int64_t senderUserID,
59+
const unsigned char* payload,
60+
int payloadLen);
61+
62+
// True if the bridge currently has an active voice session (lobby
63+
// or in-game). Used to gate UI widgets.
64+
static bool IsActive();
65+
};
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// VoiceCapture.h
2+
//
3+
// WASAPI-based microphone capture for lobby voice chat. Runs its own
4+
// worker thread that pulls packets from the OS capture endpoint,
5+
// resamples/converts to 48 kHz mono PCM16 if needed, and hands
6+
// completed 20 ms frames to a caller-supplied callback. The callback
7+
// is invoked on the capture worker thread - callers must be
8+
// thread-safe or marshal work back to the main thread themselves.
9+
//
10+
// Lifecycle:
11+
// 1. Construct (cheap, does nothing).
12+
// 2. Start() - opens the default capture endpoint, starts the
13+
// worker thread, begins delivering frames. Returns false if no
14+
// microphone is available or WASAPI initialisation fails.
15+
// 3. SetTransmitting(true/false) - gates whether the worker actually
16+
// calls the frame callback. When false the mic is still open but
17+
// frames are discarded. This models push-to-talk without
18+
// constantly stopping/restarting the capture stream.
19+
// 4. Stop() - joins the worker and releases the endpoint.
20+
// 5. Destruct.
21+
//
22+
// The capture format is requested as 48 kHz mono PCM16 shared-mode. If
23+
// the endpoint rejects that, we fall back to the endpoint's native
24+
// mix format and convert on the fly using Windows' automatic format
25+
// conversion inside IAudioClient (AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM
26+
// plus AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY).
27+
28+
#pragma once
29+
30+
#ifdef ENABLE_VOICE_CHAT
31+
32+
#include <cstdint>
33+
#include <functional>
34+
#include <atomic>
35+
#include <thread>
36+
#include <string>
37+
#include <vector>
38+
39+
namespace Voice
40+
{
41+
42+
// Callback signature: receives one 20 ms frame of 48 kHz mono PCM16
43+
// (960 int16 samples). The pointer is valid only for the duration of
44+
// the call; copy if you need to keep it.
45+
using CaptureFrameCallback = std::function<void(const int16_t* pcm, int sampleCount)>;
46+
47+
// Description of a capture endpoint as returned by
48+
// VoiceCapture::EnumerateDevices. The id is the opaque WASAPI endpoint
49+
// id string (suitable for IMMDeviceEnumerator::GetDevice); the
50+
// friendlyName is for presenting to the user.
51+
struct CaptureDeviceInfo
52+
{
53+
std::wstring id;
54+
std::wstring friendlyName;
55+
bool isDefaultCommunications = false;
56+
bool isDefaultConsole = false;
57+
};
58+
59+
class VoiceCapture
60+
{
61+
public:
62+
VoiceCapture();
63+
~VoiceCapture();
64+
65+
VoiceCapture(const VoiceCapture&) = delete;
66+
VoiceCapture& operator=(const VoiceCapture&) = delete;
67+
68+
// Opens the default capture endpoint and starts the worker thread.
69+
// Returns false on any failure (no mic, no permission, WASAPI
70+
// unavailable); the object is safe to destruct in that state.
71+
bool Start(CaptureFrameCallback onFrame);
72+
73+
// Stops the worker thread and releases the WASAPI objects. Safe
74+
// to call multiple times.
75+
void Stop();
76+
77+
// When false, captured audio is dropped on the floor instead of
78+
// being passed to the callback. This is how push-to-talk is
79+
// implemented: the mic stream keeps running (avoids click/pop on
80+
// enable) but frames only reach the network path while the PTT
81+
// key is held.
82+
void SetTransmitting(bool transmitting);
83+
84+
bool IsRunning() const { return m_workerRunning.load(); }
85+
86+
// Peak level of the most recent transmitted frame, 0.0 - 1.0.
87+
// Useful for a "mic level" indicator in the UI.
88+
float GetCurrentLevel() const { return m_currentLevel.load(); }
89+
90+
// ---------- Device selection -----------------------------------
91+
// Enumerate all active capture endpoints. Safe to call before or
92+
// after Start() - uses its own temporary device enumerator so it
93+
// doesn't touch the running capture stream.
94+
static std::vector<CaptureDeviceInfo> EnumerateDevices();
95+
96+
// Select a capture endpoint by its WASAPI id string. Pass an empty
97+
// string to go back to the Windows default communications endpoint.
98+
// Only takes effect on the next Start(): call Stop() then Start()
99+
// again to apply at runtime.
100+
void SetDeviceID(const std::wstring& id) { m_requestedDeviceID = id; }
101+
const std::wstring& GetDeviceID() const { return m_requestedDeviceID; }
102+
103+
// ---------- Mic gain -------------------------------------------
104+
// Linear multiplier applied to captured samples before Opus encode.
105+
// 1.0 = unity (no change), 2.0 = +6 dB, 0.0 = mute.
106+
// Clamped to [0.0, 4.0] internally. Thread-safe.
107+
void SetMicGain(float gain);
108+
float GetMicGain() const { return m_micGain.load(); }
109+
110+
private:
111+
// Runs on the worker thread.
112+
void WorkerLoop();
113+
114+
// Opaque forward declaration so the WASAPI headers don't leak
115+
// through to every TU that includes this file.
116+
struct Impl;
117+
Impl* m_impl;
118+
119+
CaptureFrameCallback m_onFrame;
120+
std::atomic<bool> m_workerRunning;
121+
std::atomic<bool> m_workerShouldExit;
122+
std::atomic<bool> m_transmitting;
123+
std::atomic<float> m_currentLevel;
124+
std::atomic<float> m_micGain{1.0f};
125+
std::wstring m_requestedDeviceID; // empty => default comms
126+
std::thread m_workerThread;
127+
};
128+
129+
} // namespace Voice
130+
131+
#endif // ENABLE_VOICE_CHAT

0 commit comments

Comments
 (0)