784242fcf7
- fixed the position of the speaker indicator. constant aspect ratio added - fixed voice chat functionality to work with the latest commits - add support for cross compiling windows64 builds on linux
891 lines
26 KiB
C++
891 lines
26 KiB
C++
#include "stdafx.h"
|
|
#include "VoiceChatManager.h"
|
|
#include "Minecraft.h"
|
|
#include "MultiPlayerLocalPlayer.h"
|
|
#include "ClientConnection.h"
|
|
#include "..\Minecraft.World\net.minecraft.network.packet.h"
|
|
#include "..\Minecraft.World\net.minecraft.world.entity.player.h"
|
|
#include "..\Minecraft.World\Connection.h"
|
|
#include "..\Minecraft.World\System.h"
|
|
#include <cstring>
|
|
#include <cmath>
|
|
|
|
// Include miniaudio
|
|
#include "Common\Audio\miniaudio.h"
|
|
|
|
namespace
|
|
{
|
|
static const int MIN_VOICE_ACTIVATION_GAIN_PERCENT = 25;
|
|
static const int MAX_VOICE_ACTIVATION_GAIN_PERCENT = 400;
|
|
static const int VOICE_ACTIVATION_OPEN_PEAK = 900;
|
|
static const int VOICE_ACTIVATION_OPEN_RMS = 220;
|
|
static const int VOICE_ACTIVATION_CLOSE_PEAK = 600;
|
|
static const int VOICE_ACTIVATION_CLOSE_RMS = 160;
|
|
static const int VOICE_ACTIVATION_HOLD_FRAMES = 16;
|
|
static const int PUSH_TO_TALK_GATE_PEAK = 450;
|
|
static const int PUSH_TO_TALK_GATE_RMS = 120;
|
|
static const int PUSH_TO_TALK_HOLD_FRAMES = 4;
|
|
static const float CAPTURE_HIGHPASS_ALPHA = 0.995f;
|
|
static const int JITTER_BUFFER_TARGET_PACKETS = 14; // 140ms target playout delay
|
|
static const int JITTER_BUFFER_MIN_PACKETS = 10; // 100ms to restart after starvation
|
|
static const int JITTER_BUFFER_MAX_PACKETS = 30; // cap added latency at 300ms
|
|
static const int MAX_CONCEAL_PACKETS = 12; // up to 120ms concealment per gap
|
|
static const float CONCEAL_DECAY = 0.992f;
|
|
|
|
static void RefreshDeviceLists(
|
|
std::vector<std::wstring> &playbackDevices,
|
|
std::vector<std::wstring> &captureDevices,
|
|
ma_device_info *pPlaybackInfos,
|
|
ma_uint32 playbackCount,
|
|
ma_device_info *pCaptureInfos,
|
|
ma_uint32 captureCount)
|
|
{
|
|
playbackDevices.clear();
|
|
captureDevices.clear();
|
|
|
|
for (ma_uint32 i = 0; i < playbackCount; ++i)
|
|
{
|
|
const char *name = pPlaybackInfos[i].name;
|
|
playbackDevices.emplace_back(name, name + strlen(name));
|
|
}
|
|
|
|
for (ma_uint32 i = 0; i < captureCount; ++i)
|
|
{
|
|
const char *name = pCaptureInfos[i].name;
|
|
captureDevices.emplace_back(name, name + strlen(name));
|
|
}
|
|
}
|
|
|
|
static const ma_device_id *GetSelectedDeviceId(int selectedIndex, ma_device_info *pInfos, ma_uint32 count)
|
|
{
|
|
if (selectedIndex < 0) return nullptr; // default device
|
|
if (selectedIndex >= static_cast<int>(count)) return nullptr;
|
|
return &pInfos[selectedIndex].id;
|
|
}
|
|
|
|
static int SampleMagnitude(int sample)
|
|
{
|
|
return sample >= 0 ? sample : -sample;
|
|
}
|
|
|
|
static short ClampSampleToShort(int sample)
|
|
{
|
|
if (sample > 32767) return 32767;
|
|
if (sample < -32768) return -32768;
|
|
return static_cast<short>(sample);
|
|
}
|
|
|
|
static int RingBufferedSamples(int writePos, int readPos, int capacity)
|
|
{
|
|
return (writePos - readPos + capacity) % capacity;
|
|
}
|
|
|
|
struct VoiceFrameMetrics
|
|
{
|
|
int peak;
|
|
int rms;
|
|
};
|
|
|
|
static VoiceFrameMetrics MeasureVoiceFrame(const short *samples, int sampleCount, int gainPercent)
|
|
{
|
|
VoiceFrameMetrics metrics = { 0, 0 };
|
|
if (samples == nullptr || sampleCount <= 0)
|
|
{
|
|
return metrics;
|
|
}
|
|
|
|
long long energy = 0;
|
|
for (int i = 0; i < sampleCount; ++i)
|
|
{
|
|
int sample = (static_cast<int>(samples[i]) * gainPercent) / 100;
|
|
const int magnitude = SampleMagnitude(sample);
|
|
if (magnitude > metrics.peak)
|
|
{
|
|
metrics.peak = magnitude;
|
|
}
|
|
energy += static_cast<long long>(magnitude) * static_cast<long long>(magnitude);
|
|
}
|
|
|
|
metrics.rms = static_cast<int>(std::sqrt(static_cast<double>(energy) / static_cast<double>(sampleCount)));
|
|
return metrics;
|
|
}
|
|
}
|
|
|
|
VoiceChatManager &VoiceChatManager::getInstance()
|
|
{
|
|
static VoiceChatManager instance;
|
|
return instance;
|
|
}
|
|
|
|
VoiceChatManager::VoiceChatManager()
|
|
: m_initialized(false)
|
|
, m_audioContext(nullptr)
|
|
, m_captureDevice(nullptr)
|
|
, m_playbackDevice(nullptr)
|
|
, m_captureWritePos(0)
|
|
, m_captureReadPos(0)
|
|
, m_listenerX(0), m_listenerY(0), m_listenerZ(0)
|
|
, m_isPushToTalkActive(false)
|
|
, m_voiceInputMode(VOICE_INPUT_PUSH_TO_TALK)
|
|
, m_proximityEnabled(true)
|
|
, m_selectedCaptureDevice(-1)
|
|
, m_selectedPlaybackDevice(-1)
|
|
, m_localSequence(0)
|
|
, m_micVolumePercent(100)
|
|
, m_voiceChatVolumePercent(100)
|
|
, m_voiceActivationGainPercent(100)
|
|
, m_voiceActivationHoldFrames(0)
|
|
, m_pushToTalkHoldFrames(0)
|
|
, m_captureFilterLastInput(0.0f)
|
|
, m_captureFilterLastOutput(0.0f)
|
|
, m_localMuted(false)
|
|
{
|
|
memset(m_captureBuffer, 0, sizeof(m_captureBuffer));
|
|
InitializeCriticalSection(&m_captureLock);
|
|
InitializeCriticalSection(&m_playbackLock);
|
|
InitializeCriticalSection(&m_speakingLock);
|
|
}
|
|
|
|
VoiceChatManager::~VoiceChatManager()
|
|
{
|
|
shutdown();
|
|
DeleteCriticalSection(&m_captureLock);
|
|
DeleteCriticalSection(&m_playbackLock);
|
|
DeleteCriticalSection(&m_speakingLock);
|
|
}
|
|
|
|
void VoiceChatManager::onCaptureAudio(ma_device *pDevice, void *pOutput, const void *pInput, unsigned int frameCount)
|
|
{
|
|
VoiceChatManager *mgr = (VoiceChatManager *)pDevice->pUserData;
|
|
const short *input = (const short *)pInput;
|
|
|
|
if (input == nullptr || mgr == nullptr) return;
|
|
|
|
EnterCriticalSection(&mgr->m_captureLock);
|
|
for (unsigned int i = 0; i < frameCount; i++)
|
|
{
|
|
const int nextWritePos = (mgr->m_captureWritePos + 1) % CAPTURE_BUFFER_SIZE;
|
|
if (nextWritePos == mgr->m_captureReadPos)
|
|
{
|
|
// Capture overrun: drop oldest sample to preserve continuity.
|
|
mgr->m_captureReadPos = (mgr->m_captureReadPos + 1) % CAPTURE_BUFFER_SIZE;
|
|
}
|
|
mgr->m_captureBuffer[mgr->m_captureWritePos] = input[i];
|
|
mgr->m_captureWritePos = nextWritePos;
|
|
}
|
|
LeaveCriticalSection(&mgr->m_captureLock);
|
|
}
|
|
|
|
void VoiceChatManager::onPlaybackAudio(ma_device *pDevice, void *pOutput, const void *pInput, unsigned int frameCount)
|
|
{
|
|
VoiceChatManager *mgr = (VoiceChatManager *)pDevice->pUserData;
|
|
short *output = (short *)pOutput;
|
|
|
|
if (output == nullptr || mgr == nullptr) return;
|
|
|
|
memset(output, 0, frameCount * sizeof(short));
|
|
|
|
EnterCriticalSection(&mgr->m_playbackLock);
|
|
for (auto &pair : mgr->m_remoteStreams)
|
|
{
|
|
RemoteVoiceStream &stream = pair.second;
|
|
|
|
float volume = 1.0f;
|
|
|
|
if (mgr->m_proximityEnabled)
|
|
{
|
|
// Calculate distance attenuation
|
|
double dx = stream.x - mgr->m_listenerX;
|
|
double dy = stream.y - mgr->m_listenerY;
|
|
double dz = stream.z - mgr->m_listenerZ;
|
|
double dist = sqrt(dx * dx + dy * dy + dz * dz);
|
|
|
|
if (dist >= MAX_VOICE_DISTANCE)
|
|
{
|
|
volume = 0.0f;
|
|
}
|
|
else if (dist > 1.0)
|
|
{
|
|
// Linear falloff with exponential tail
|
|
float linearFactor = 1.0f - (float)(dist / MAX_VOICE_DISTANCE);
|
|
float expFactor = (float)exp(-dist * 0.08);
|
|
volume = linearFactor * expFactor;
|
|
}
|
|
}
|
|
|
|
if (volume <= 0.001f) continue;
|
|
volume *= (float)mgr->m_voiceChatVolumePercent / 100.0f;
|
|
if (volume <= 0.001f) continue;
|
|
|
|
const int jitterTargetSamples = JITTER_BUFFER_TARGET_PACKETS * FRAMES_PER_PACKET;
|
|
const int jitterRestartSamples = JITTER_BUFFER_MIN_PACKETS * FRAMES_PER_PACKET;
|
|
const int jitterMaxSamples = JITTER_BUFFER_MAX_PACKETS * FRAMES_PER_PACKET;
|
|
|
|
int bufferedSamples = RingBufferedSamples(stream.writePos, stream.readPos, RemoteVoiceStream::BUFFER_SIZE);
|
|
|
|
// Keep latency bounded if packets burst in faster than real-time.
|
|
if (bufferedSamples > jitterMaxSamples)
|
|
{
|
|
int drop = bufferedSamples - jitterMaxSamples;
|
|
stream.readPos = (stream.readPos + drop) % RemoteVoiceStream::BUFFER_SIZE;
|
|
bufferedSamples -= drop;
|
|
}
|
|
|
|
// Prime before playout to absorb network/tick jitter.
|
|
if (!stream.primed)
|
|
{
|
|
if (bufferedSamples < jitterTargetSamples)
|
|
{
|
|
continue;
|
|
}
|
|
stream.primed = true;
|
|
}
|
|
|
|
// Mix this stream's audio into the output.
|
|
for (unsigned int i = 0; i < frameCount; i++)
|
|
{
|
|
bufferedSamples = RingBufferedSamples(stream.writePos, stream.readPos, RemoteVoiceStream::BUFFER_SIZE);
|
|
short sourceSample = 0;
|
|
|
|
if (bufferedSamples > 0)
|
|
{
|
|
sourceSample = stream.buffer[stream.readPos];
|
|
stream.readPos = (stream.readPos + 1) % RemoteVoiceStream::BUFFER_SIZE;
|
|
stream.lastOutputSample = sourceSample;
|
|
stream.pendingConcealSamples = 0;
|
|
}
|
|
else if (stream.pendingConcealSamples > 0)
|
|
{
|
|
const int faded = static_cast<int>(stream.lastOutputSample * CONCEAL_DECAY);
|
|
stream.lastOutputSample = ClampSampleToShort(faded);
|
|
sourceSample = stream.lastOutputSample;
|
|
--stream.pendingConcealSamples;
|
|
}
|
|
else
|
|
{
|
|
// Starved: re-prime with a smaller threshold before resuming to avoid tremble.
|
|
stream.primed = false;
|
|
const int refillBuffered = RingBufferedSamples(stream.writePos, stream.readPos, RemoteVoiceStream::BUFFER_SIZE);
|
|
if (refillBuffered < jitterRestartSamples)
|
|
{
|
|
break;
|
|
}
|
|
stream.primed = true;
|
|
}
|
|
|
|
if (sourceSample != 0)
|
|
{
|
|
int sample = static_cast<int>(sourceSample * volume);
|
|
int mixed = output[i] + sample;
|
|
output[i] = ClampSampleToShort(mixed);
|
|
}
|
|
}
|
|
}
|
|
LeaveCriticalSection(&mgr->m_playbackLock);
|
|
}
|
|
|
|
void VoiceChatManager::init()
|
|
{
|
|
if (m_initialized) return;
|
|
|
|
m_captureWritePos = 0;
|
|
m_captureReadPos = 0;
|
|
m_voiceActivationHoldFrames = 0;
|
|
m_pushToTalkHoldFrames = 0;
|
|
m_captureFilterLastInput = 0.0f;
|
|
m_captureFilterLastOutput = 0.0f;
|
|
memset(m_captureBuffer, 0, sizeof(m_captureBuffer));
|
|
|
|
m_audioContext = new ma_context();
|
|
if (ma_context_init(nullptr, 0, nullptr, m_audioContext) != MA_SUCCESS)
|
|
{
|
|
app.DebugPrintf("VoiceChat: Failed to initialize audio context\n");
|
|
delete m_audioContext;
|
|
m_audioContext = nullptr;
|
|
return;
|
|
}
|
|
|
|
ma_device_info *pPlaybackInfos = nullptr;
|
|
ma_uint32 playbackCount = 0;
|
|
ma_device_info *pCaptureInfos = nullptr;
|
|
ma_uint32 captureCount = 0;
|
|
if (ma_context_get_devices(m_audioContext, &pPlaybackInfos, &playbackCount, &pCaptureInfos, &captureCount) != MA_SUCCESS)
|
|
{
|
|
app.DebugPrintf("VoiceChat: Failed to enumerate audio devices\n");
|
|
ma_context_uninit(m_audioContext);
|
|
delete m_audioContext;
|
|
m_audioContext = nullptr;
|
|
return;
|
|
}
|
|
|
|
RefreshDeviceLists(m_playbackDevices, m_captureDevices, pPlaybackInfos, playbackCount, pCaptureInfos, captureCount);
|
|
if (m_selectedCaptureDevice >= static_cast<int>(m_captureDevices.size())) m_selectedCaptureDevice = -1;
|
|
if (m_selectedPlaybackDevice >= static_cast<int>(m_playbackDevices.size())) m_selectedPlaybackDevice = -1;
|
|
|
|
// Initialize capture device (microphone)
|
|
m_captureDevice = new ma_device();
|
|
ma_device_config captureConfig = ma_device_config_init(ma_device_type_capture);
|
|
captureConfig.capture.format = ma_format_s16;
|
|
captureConfig.capture.channels = CHANNELS;
|
|
captureConfig.capture.pDeviceID = GetSelectedDeviceId(m_selectedCaptureDevice, pCaptureInfos, captureCount);
|
|
captureConfig.sampleRate = SAMPLE_RATE;
|
|
captureConfig.periodSizeInFrames = FRAMES_PER_PACKET;
|
|
captureConfig.periods = 3;
|
|
captureConfig.dataCallback = onCaptureAudio;
|
|
captureConfig.pUserData = this;
|
|
|
|
if (ma_device_init(m_audioContext, &captureConfig, m_captureDevice) != MA_SUCCESS)
|
|
{
|
|
app.DebugPrintf("VoiceChat: Failed to initialize capture device\n");
|
|
delete m_captureDevice;
|
|
m_captureDevice = nullptr;
|
|
}
|
|
else if (ma_device_start(m_captureDevice) != MA_SUCCESS)
|
|
{
|
|
app.DebugPrintf("VoiceChat: Failed to start capture device\n");
|
|
ma_device_uninit(m_captureDevice);
|
|
delete m_captureDevice;
|
|
m_captureDevice = nullptr;
|
|
}
|
|
|
|
// Initialize playback device (speakers)
|
|
m_playbackDevice = new ma_device();
|
|
ma_device_config playbackConfig = ma_device_config_init(ma_device_type_playback);
|
|
playbackConfig.playback.format = ma_format_s16;
|
|
playbackConfig.playback.channels = CHANNELS;
|
|
playbackConfig.playback.pDeviceID = GetSelectedDeviceId(m_selectedPlaybackDevice, pPlaybackInfos, playbackCount);
|
|
playbackConfig.sampleRate = SAMPLE_RATE;
|
|
playbackConfig.periodSizeInFrames = FRAMES_PER_PACKET;
|
|
playbackConfig.periods = 3;
|
|
playbackConfig.dataCallback = onPlaybackAudio;
|
|
playbackConfig.pUserData = this;
|
|
|
|
if (ma_device_init(m_audioContext, &playbackConfig, m_playbackDevice) != MA_SUCCESS)
|
|
{
|
|
app.DebugPrintf("VoiceChat: Failed to initialize playback device\n");
|
|
delete m_playbackDevice;
|
|
m_playbackDevice = nullptr;
|
|
// Still keep capture running
|
|
}
|
|
else
|
|
{
|
|
if (ma_device_start(m_playbackDevice) != MA_SUCCESS)
|
|
{
|
|
app.DebugPrintf("VoiceChat: Failed to start playback device\n");
|
|
ma_device_uninit(m_playbackDevice);
|
|
delete m_playbackDevice;
|
|
m_playbackDevice = nullptr;
|
|
}
|
|
}
|
|
|
|
m_initialized = (m_captureDevice != nullptr || m_playbackDevice != nullptr);
|
|
if (!m_initialized)
|
|
{
|
|
ma_context_uninit(m_audioContext);
|
|
delete m_audioContext;
|
|
m_audioContext = nullptr;
|
|
}
|
|
|
|
app.DebugPrintf("VoiceChat: Initialized (capture=%s, playback=%s, mode=%s, proximity=%s)\n",
|
|
m_captureDevice ? "OK" : "FAIL",
|
|
m_playbackDevice ? "OK" : "FAIL",
|
|
m_voiceInputMode == VOICE_INPUT_PUSH_TO_TALK ? "PTT" : "VAD",
|
|
m_proximityEnabled ? "ON" : "OFF");
|
|
}
|
|
|
|
void VoiceChatManager::shutdown()
|
|
{
|
|
if (!m_initialized) return;
|
|
|
|
if (m_captureDevice)
|
|
{
|
|
ma_device_uninit(m_captureDevice);
|
|
delete m_captureDevice;
|
|
m_captureDevice = nullptr;
|
|
}
|
|
|
|
if (m_playbackDevice)
|
|
{
|
|
ma_device_uninit(m_playbackDevice);
|
|
delete m_playbackDevice;
|
|
m_playbackDevice = nullptr;
|
|
}
|
|
|
|
EnterCriticalSection(&m_playbackLock);
|
|
m_remoteStreams.clear();
|
|
LeaveCriticalSection(&m_playbackLock);
|
|
|
|
if (m_audioContext)
|
|
{
|
|
ma_context_uninit(m_audioContext);
|
|
delete m_audioContext;
|
|
m_audioContext = nullptr;
|
|
}
|
|
m_captureDevices.clear();
|
|
m_playbackDevices.clear();
|
|
m_voiceActivationHoldFrames = 0;
|
|
m_pushToTalkHoldFrames = 0;
|
|
m_captureFilterLastInput = 0.0f;
|
|
m_captureFilterLastOutput = 0.0f;
|
|
|
|
m_initialized = false;
|
|
app.DebugPrintf("VoiceChat: Shutdown\n");
|
|
}
|
|
|
|
void VoiceChatManager::setVoiceInputMode(VoiceInputMode mode)
|
|
{
|
|
if (m_voiceInputMode == mode)
|
|
{
|
|
return;
|
|
}
|
|
|
|
m_voiceInputMode = mode;
|
|
m_voiceActivationHoldFrames = 0;
|
|
m_pushToTalkHoldFrames = 0;
|
|
app.DebugPrintf("VoiceChat: Input mode set to %s\n", m_voiceInputMode == VOICE_INPUT_PUSH_TO_TALK ? "PTT" : "VoiceActivation");
|
|
}
|
|
|
|
void VoiceChatManager::toggleVoiceInputMode()
|
|
{
|
|
if (m_voiceInputMode == VOICE_INPUT_PUSH_TO_TALK)
|
|
{
|
|
setVoiceInputMode(VOICE_INPUT_VOICE_ACTIVATION);
|
|
}
|
|
else
|
|
{
|
|
setVoiceInputMode(VOICE_INPUT_PUSH_TO_TALK);
|
|
}
|
|
}
|
|
|
|
void VoiceChatManager::toggleProximityEnabled()
|
|
{
|
|
m_proximityEnabled = !m_proximityEnabled;
|
|
app.DebugPrintf("VoiceChat: Proximity %s\n", m_proximityEnabled ? "enabled" : "disabled");
|
|
}
|
|
|
|
void VoiceChatManager::setProximityEnabled(bool enabled)
|
|
{
|
|
if (m_proximityEnabled == enabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
m_proximityEnabled = enabled;
|
|
app.DebugPrintf("VoiceChat: Proximity %s\n", m_proximityEnabled ? "enabled" : "disabled");
|
|
}
|
|
|
|
bool VoiceChatManager::cycleCaptureDevice(int dir)
|
|
{
|
|
if (!m_initialized) init();
|
|
if (m_captureDevices.empty()) return false;
|
|
|
|
const int totalSlots = static_cast<int>(m_captureDevices.size()) + 1; // default + explicit devices
|
|
int slot = m_selectedCaptureDevice + 1; // -1 -> 0
|
|
slot = (slot + dir) % totalSlots;
|
|
if (slot < 0) slot += totalSlots;
|
|
m_selectedCaptureDevice = slot - 1;
|
|
|
|
shutdown();
|
|
init();
|
|
|
|
app.DebugPrintf("VoiceChat: Capture device -> %ls\n", getSelectedCaptureDeviceName().c_str());
|
|
return m_initialized;
|
|
}
|
|
|
|
bool VoiceChatManager::cyclePlaybackDevice(int dir)
|
|
{
|
|
if (!m_initialized) init();
|
|
if (m_playbackDevices.empty()) return false;
|
|
|
|
const int totalSlots = static_cast<int>(m_playbackDevices.size()) + 1; // default + explicit devices
|
|
int slot = m_selectedPlaybackDevice + 1; // -1 -> 0
|
|
slot = (slot + dir) % totalSlots;
|
|
if (slot < 0) slot += totalSlots;
|
|
m_selectedPlaybackDevice = slot - 1;
|
|
|
|
shutdown();
|
|
init();
|
|
|
|
app.DebugPrintf("VoiceChat: Playback device -> %ls\n", getSelectedPlaybackDeviceName().c_str());
|
|
return m_initialized;
|
|
}
|
|
|
|
wstring VoiceChatManager::getSelectedCaptureDeviceName() const
|
|
{
|
|
if (m_selectedCaptureDevice < 0 || m_selectedCaptureDevice >= static_cast<int>(m_captureDevices.size()))
|
|
{
|
|
return L"Default";
|
|
}
|
|
return m_captureDevices[m_selectedCaptureDevice];
|
|
}
|
|
|
|
wstring VoiceChatManager::getSelectedPlaybackDeviceName() const
|
|
{
|
|
if (m_selectedPlaybackDevice < 0 || m_selectedPlaybackDevice >= static_cast<int>(m_playbackDevices.size()))
|
|
{
|
|
return L"Default";
|
|
}
|
|
return m_playbackDevices[m_selectedPlaybackDevice];
|
|
}
|
|
|
|
void VoiceChatManager::getCaptureDeviceNames(vector<wstring> &outNames, bool includeDefault) const
|
|
{
|
|
outNames.clear();
|
|
if (includeDefault) outNames.push_back(L"Default");
|
|
for (const auto &name : m_captureDevices)
|
|
{
|
|
outNames.push_back(name);
|
|
}
|
|
}
|
|
|
|
void VoiceChatManager::getPlaybackDeviceNames(vector<wstring> &outNames, bool includeDefault) const
|
|
{
|
|
outNames.clear();
|
|
if (includeDefault) outNames.push_back(L"Default");
|
|
for (const auto &name : m_playbackDevices)
|
|
{
|
|
outNames.push_back(name);
|
|
}
|
|
}
|
|
|
|
int VoiceChatManager::getSelectedCaptureMenuIndex() const
|
|
{
|
|
return m_selectedCaptureDevice + 1;
|
|
}
|
|
|
|
int VoiceChatManager::getSelectedPlaybackMenuIndex() const
|
|
{
|
|
return m_selectedPlaybackDevice + 1;
|
|
}
|
|
|
|
bool VoiceChatManager::selectCaptureMenuIndex(int menuIndex)
|
|
{
|
|
if (!m_initialized) init();
|
|
const int maxIndex = static_cast<int>(m_captureDevices.size());
|
|
if (menuIndex < 0 || menuIndex > maxIndex) return false;
|
|
|
|
const int nextSelected = menuIndex - 1;
|
|
if (nextSelected == m_selectedCaptureDevice) return true;
|
|
|
|
m_selectedCaptureDevice = nextSelected;
|
|
shutdown();
|
|
init();
|
|
return m_initialized;
|
|
}
|
|
|
|
bool VoiceChatManager::selectPlaybackMenuIndex(int menuIndex)
|
|
{
|
|
if (!m_initialized) init();
|
|
const int maxIndex = static_cast<int>(m_playbackDevices.size());
|
|
if (menuIndex < 0 || menuIndex > maxIndex) return false;
|
|
|
|
const int nextSelected = menuIndex - 1;
|
|
if (nextSelected == m_selectedPlaybackDevice) return true;
|
|
|
|
m_selectedPlaybackDevice = nextSelected;
|
|
shutdown();
|
|
init();
|
|
return m_initialized;
|
|
}
|
|
|
|
void VoiceChatManager::tick(Minecraft *minecraft)
|
|
{
|
|
if (!m_initialized || m_captureDevice == nullptr) return;
|
|
if (minecraft == nullptr || minecraft->player == nullptr) return;
|
|
|
|
int iPad = minecraft->player->GetXboxPad();
|
|
ClientConnection *conn = minecraft->getConnection(iPad);
|
|
if (conn == nullptr) return;
|
|
|
|
// Update listener position
|
|
setListenerPosition(minecraft->player->x, minecraft->player->y, minecraft->player->z);
|
|
|
|
// Read captured audio and send as packets
|
|
EnterCriticalSection(&m_captureLock);
|
|
|
|
int available = (m_captureWritePos - m_captureReadPos + CAPTURE_BUFFER_SIZE) % CAPTURE_BUFFER_SIZE;
|
|
|
|
// Send in short frames to keep UDP payloads under common MTU limits.
|
|
while (available >= FRAMES_PER_PACKET)
|
|
{
|
|
short frameBuffer[FRAMES_PER_PACKET];
|
|
for (int i = 0; i < FRAMES_PER_PACKET; i++)
|
|
{
|
|
int sample = m_captureBuffer[m_captureReadPos];
|
|
sample = (sample * m_micVolumePercent) / 100;
|
|
|
|
// Strip DC bias and low-frequency rumble so we do not transmit a bed of hiss/hum when the mic is idle.
|
|
const float filteredSample =
|
|
static_cast<float>(sample) - m_captureFilterLastInput + (CAPTURE_HIGHPASS_ALPHA * m_captureFilterLastOutput);
|
|
m_captureFilterLastInput = static_cast<float>(sample);
|
|
m_captureFilterLastOutput = filteredSample;
|
|
|
|
frameBuffer[i] = ClampSampleToShort(static_cast<int>(filteredSample));
|
|
m_captureReadPos = (m_captureReadPos + 1) % CAPTURE_BUFFER_SIZE;
|
|
}
|
|
|
|
const VoiceFrameMetrics pushToTalkMetrics = MeasureVoiceFrame(frameBuffer, FRAMES_PER_PACKET, 100);
|
|
bool pushToTalkSpeechDetected =
|
|
pushToTalkMetrics.peak >= PUSH_TO_TALK_GATE_PEAK ||
|
|
pushToTalkMetrics.rms >= PUSH_TO_TALK_GATE_RMS;
|
|
|
|
if (m_isPushToTalkActive)
|
|
{
|
|
if (pushToTalkSpeechDetected)
|
|
{
|
|
m_pushToTalkHoldFrames = PUSH_TO_TALK_HOLD_FRAMES;
|
|
}
|
|
else if (m_pushToTalkHoldFrames > 0)
|
|
{
|
|
--m_pushToTalkHoldFrames;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
m_pushToTalkHoldFrames = 0;
|
|
}
|
|
|
|
const VoiceFrameMetrics voiceActivationMetrics =
|
|
MeasureVoiceFrame(frameBuffer, FRAMES_PER_PACKET, m_voiceActivationGainPercent);
|
|
const bool useCloseThresholds = m_voiceActivationHoldFrames > 0;
|
|
const int requiredPeak = useCloseThresholds ? VOICE_ACTIVATION_CLOSE_PEAK : VOICE_ACTIVATION_OPEN_PEAK;
|
|
const int requiredRms = useCloseThresholds ? VOICE_ACTIVATION_CLOSE_RMS : VOICE_ACTIVATION_OPEN_RMS;
|
|
const bool voiceActivationDetected =
|
|
voiceActivationMetrics.peak >= requiredPeak ||
|
|
voiceActivationMetrics.rms >= requiredRms;
|
|
|
|
if (voiceActivationDetected)
|
|
{
|
|
m_voiceActivationHoldFrames = VOICE_ACTIVATION_HOLD_FRAMES;
|
|
}
|
|
else if (m_voiceActivationHoldFrames > 0)
|
|
{
|
|
--m_voiceActivationHoldFrames;
|
|
}
|
|
|
|
bool shouldSend = false;
|
|
if (m_voiceInputMode == VOICE_INPUT_PUSH_TO_TALK)
|
|
{
|
|
// PTT should be stable while held; avoid extra per-frame speech gating cuts.
|
|
shouldSend = m_isPushToTalkActive;
|
|
}
|
|
else
|
|
{
|
|
shouldSend = m_voiceActivationHoldFrames > 0;
|
|
}
|
|
|
|
if (m_localMuted)
|
|
{
|
|
shouldSend = false;
|
|
}
|
|
|
|
if (shouldSend)
|
|
{
|
|
// Mark our local player as speaking for the indicator
|
|
markSpeaking(minecraft->player->entityId);
|
|
|
|
// Send raw PCM as a voice chat packet
|
|
short dataLen = (short)(FRAMES_PER_PACKET * sizeof(short));
|
|
byteArray audioData(dataLen);
|
|
memcpy(audioData.data, frameBuffer, dataLen);
|
|
|
|
shared_ptr<Packet> packet = std::make_shared<VoiceChatPacket>(
|
|
minecraft->player->entityId, m_localSequence++, audioData, dataLen);
|
|
|
|
// Queue to slow path so it is emitted as standalone UDP transport packets.
|
|
conn->queueSend(packet);
|
|
}
|
|
|
|
available = (m_captureWritePos - m_captureReadPos + CAPTURE_BUFFER_SIZE) % CAPTURE_BUFFER_SIZE;
|
|
}
|
|
|
|
LeaveCriticalSection(&m_captureLock);
|
|
|
|
if (m_localMuted)
|
|
{
|
|
clearSpeaking(minecraft->player->entityId);
|
|
}
|
|
|
|
// Cleanup stale remote streams (no data for >2 seconds)
|
|
int64_t now = System::currentTimeMillis();
|
|
EnterCriticalSection(&m_playbackLock);
|
|
for (auto it = m_remoteStreams.begin(); it != m_remoteStreams.end(); )
|
|
{
|
|
if (now - it->second.lastReceiveTime > 2000)
|
|
{
|
|
it = m_remoteStreams.erase(it);
|
|
}
|
|
else
|
|
{
|
|
++it;
|
|
}
|
|
}
|
|
LeaveCriticalSection(&m_playbackLock);
|
|
|
|
// Tick speaking indicators
|
|
tickSpeakingState();
|
|
}
|
|
|
|
void VoiceChatManager::receiveVoiceData(int playerId, unsigned short sequence, double x, double y, double z, const unsigned char *data, int dataLength)
|
|
{
|
|
if (!m_initialized || m_playbackDevice == nullptr) return;
|
|
if (data == nullptr || dataLength <= 0) return;
|
|
|
|
int sampleCount = dataLength / sizeof(short);
|
|
const short *samples = (const short *)data;
|
|
|
|
EnterCriticalSection(&m_playbackLock);
|
|
|
|
RemoteVoiceStream &stream = m_remoteStreams[playerId];
|
|
stream.x = x;
|
|
stream.y = y;
|
|
stream.z = z;
|
|
stream.lastReceiveTime = System::currentTimeMillis();
|
|
|
|
// Reject very old/out-of-order packets aggressively, but tolerate wrap-around.
|
|
if (stream.hasSequence)
|
|
{
|
|
unsigned short delta = static_cast<unsigned short>(sequence - stream.lastSequence);
|
|
if (delta == 0)
|
|
{
|
|
LeaveCriticalSection(&m_playbackLock);
|
|
return; // duplicate
|
|
}
|
|
if (delta > 32768)
|
|
{
|
|
LeaveCriticalSection(&m_playbackLock);
|
|
return; // stale packet
|
|
}
|
|
|
|
// Track short packet gaps; playback callback will synthesize a smooth decay.
|
|
const int missingPackets = static_cast<int>(delta) - 1;
|
|
if (missingPackets > 0)
|
|
{
|
|
const int concealPackets = (missingPackets > MAX_CONCEAL_PACKETS) ? MAX_CONCEAL_PACKETS : missingPackets;
|
|
stream.pendingConcealSamples += concealPackets * FRAMES_PER_PACKET;
|
|
const int maxConcealSamples = MAX_CONCEAL_PACKETS * FRAMES_PER_PACKET;
|
|
if (stream.pendingConcealSamples > maxConcealSamples)
|
|
{
|
|
stream.pendingConcealSamples = maxConcealSamples;
|
|
}
|
|
}
|
|
}
|
|
|
|
stream.lastSequence = sequence;
|
|
stream.hasSequence = true;
|
|
|
|
for (int i = 0; i < sampleCount; i++)
|
|
{
|
|
const int nextWritePos = (stream.writePos + 1) % RemoteVoiceStream::BUFFER_SIZE;
|
|
if (nextWritePos == stream.readPos)
|
|
{
|
|
// Playback overrun: drop oldest sample to keep stream moving.
|
|
stream.readPos = (stream.readPos + 1) % RemoteVoiceStream::BUFFER_SIZE;
|
|
}
|
|
stream.buffer[stream.writePos] = samples[i];
|
|
stream.writePos = nextWritePos;
|
|
}
|
|
|
|
// Keep queue depth bounded to avoid growing latency after network bursts.
|
|
const int jitterMaxSamples = JITTER_BUFFER_MAX_PACKETS * FRAMES_PER_PACKET;
|
|
int bufferedSamples = RingBufferedSamples(stream.writePos, stream.readPos, RemoteVoiceStream::BUFFER_SIZE);
|
|
if (bufferedSamples > jitterMaxSamples)
|
|
{
|
|
const int drop = bufferedSamples - jitterMaxSamples;
|
|
stream.readPos = (stream.readPos + drop) % RemoteVoiceStream::BUFFER_SIZE;
|
|
}
|
|
|
|
LeaveCriticalSection(&m_playbackLock);
|
|
}
|
|
|
|
void VoiceChatManager::setListenerPosition(double x, double y, double z)
|
|
{
|
|
m_listenerX = x;
|
|
m_listenerY = y;
|
|
m_listenerZ = z;
|
|
}
|
|
|
|
void VoiceChatManager::markSpeaking(int entityId)
|
|
{
|
|
EnterCriticalSection(&m_speakingLock);
|
|
m_speakingEntities[entityId] = 30; // ~1.5 seconds at 20 TPS
|
|
LeaveCriticalSection(&m_speakingLock);
|
|
}
|
|
|
|
bool VoiceChatManager::isEntitySpeaking(int entityId)
|
|
{
|
|
EnterCriticalSection(&m_speakingLock);
|
|
bool speaking = m_speakingEntities.count(entityId) > 0 && m_speakingEntities[entityId] > 0;
|
|
LeaveCriticalSection(&m_speakingLock);
|
|
return speaking;
|
|
}
|
|
|
|
void VoiceChatManager::clearSpeaking(int entityId)
|
|
{
|
|
EnterCriticalSection(&m_speakingLock);
|
|
m_speakingEntities.erase(entityId);
|
|
LeaveCriticalSection(&m_speakingLock);
|
|
}
|
|
|
|
void VoiceChatManager::tickSpeakingState()
|
|
{
|
|
EnterCriticalSection(&m_speakingLock);
|
|
for (auto it = m_speakingEntities.begin(); it != m_speakingEntities.end(); )
|
|
{
|
|
it->second--;
|
|
if (it->second <= 0)
|
|
{
|
|
it = m_speakingEntities.erase(it);
|
|
}
|
|
else
|
|
{
|
|
++it;
|
|
}
|
|
}
|
|
LeaveCriticalSection(&m_speakingLock);
|
|
}
|
|
|
|
void VoiceChatManager::setMicVolumePercent(int percent)
|
|
{
|
|
if (percent < 0) percent = 0;
|
|
if (percent > 200) percent = 200;
|
|
m_micVolumePercent = percent;
|
|
}
|
|
|
|
void VoiceChatManager::setVoiceChatVolumePercent(int percent)
|
|
{
|
|
if (percent < 0) percent = 0;
|
|
if (percent > 200) percent = 200;
|
|
m_voiceChatVolumePercent = percent;
|
|
}
|
|
|
|
void VoiceChatManager::setVoiceActivationGainPercent(int percent)
|
|
{
|
|
if (percent < MIN_VOICE_ACTIVATION_GAIN_PERCENT) percent = MIN_VOICE_ACTIVATION_GAIN_PERCENT;
|
|
if (percent > MAX_VOICE_ACTIVATION_GAIN_PERCENT) percent = MAX_VOICE_ACTIVATION_GAIN_PERCENT;
|
|
m_voiceActivationGainPercent = percent;
|
|
}
|
|
|
|
void VoiceChatManager::setLocalMuted(bool muted)
|
|
{
|
|
if (m_localMuted == muted)
|
|
{
|
|
return;
|
|
}
|
|
|
|
m_localMuted = muted;
|
|
if (m_localMuted)
|
|
{
|
|
m_isPushToTalkActive = false;
|
|
m_pushToTalkHoldFrames = 0;
|
|
m_voiceActivationHoldFrames = 0;
|
|
}
|
|
|
|
app.DebugPrintf("VoiceChat: Local mic %s\n", m_localMuted ? "muted" : "unmuted");
|
|
}
|
|
|
|
void VoiceChatManager::toggleLocalMuted()
|
|
{
|
|
setLocalMuted(!m_localMuted);
|
|
}
|