This commit is contained in:
2026-07-07 21:08:52 +02:00
commit 4c20cfc716
2613 changed files with 318021 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
AddEventHandler('onClientResourceStart', function(resource)
if resource ~= GetCurrentResourceName() then
return
end
print('Starting script initialization')
-- Some people modify pma-voice and mess up the resource Kvp, which means that if someone
-- joins another server that has pma-voice, it will error out, this will catch and fix the kvp.
local success = pcall(function()
local micClicksKvp = GetResourceKvpString('pma-voice_enableMicClicks')
if not micClicksKvp then
SetResourceKvp('pma-voice_enableMicClicks', "true")
micClicks = true
else
if micClicksKvp ~= 'true' and micClicksKvp ~= 'false' then
error('Invalid Kvp, throwing error for automatic fix')
end
micClicks = micClicksKvp == "true"
end
end)
if not success then
logger.warn(
'Failed to load resource Kvp, likely was inappropriately modified by another server, resetting the Kvp.')
SetResourceKvp('pma-voice_enableMicClicks', "true")
micClicks = true
end
sendUIMessage({
uiEnabled = GetConvarInt("voice_enableUi", 1) == 1,
voiceModes = json.encode(Cfg.voiceModes),
voiceMode = mode - 1
})
local radioChannel = LocalPlayer.state.radioChannel or 0
local callChannel = LocalPlayer.state.callChannel or 0
-- Reinitialize channels if they're set.
if radioChannel ~= 0 then
setRadioChannel(radioChannel)
end
if callChannel ~= 0 then
setCallChannel(callChannel)
end
if not LocalPlayer.state.disableRadio then
LocalPlayer.state:set("disableRadio", 0, true)
end
print('Script initialization finished.')
end)

View File

@@ -0,0 +1,336 @@
local mutedPlayers = {}
-- we can't use GetConvarInt because its not a integer, and theres no way to get a float... so use a hacky way it is!
local volumes = {
-- people are setting this to 1 instead of 1.0 and expecting it to work.
['radio'] = GetConvarInt('voice_defaultRadioVolume', 60) / 100,
['call'] = GetConvarInt('voice_defaultCallVolume', 60) / 100,
['click_on'] = GetConvarInt('voice_onClickVolume', 10) / 100,
['click_off'] = GetConvarInt('voice_offClickVolume', 3) / 100,
}
radioEnabled, radioPressed, mode = true, false, GetConvarInt('voice_defaultVoiceMode', 2)
radioData = {}
callData = {}
submixIndicies = {}
--- function setVolume
--- Toggles the players volume
---@param volume number between 0 and 100
---@param volumeType string the volume type (currently radio & call) to set the volume of (opt)
function setVolume(volume, volumeType)
type_check({ volume, "number" })
local volumeFraction = volume / 100
if volumeType then
local volumeTbl = volumes[volumeType]
if volumeTbl then
LocalPlayer.state:set(volumeType, volume, true)
volumes[volumeType] = volumeFraction
resyncVolume(volumeType, volumeFraction)
else
error(('setVolume got a invalid volume type %s'):format(volumeType))
end
else
for volumeType, _ in pairs(volumes) do
volumes[volumeType] = volumeFraction
LocalPlayer.state:set(volumeType, volume, true)
end
resyncVolume("all", volumeFraction)
end
end
exports('setRadioVolume', function(vol)
setVolume(vol, 'radio')
end)
exports('getRadioVolume', function()
return volumes['radio'] * 100
end)
exports("setCallVolume", function(vol)
setVolume(vol, 'call')
end)
exports('getCallVolume', function()
return volumes['call'] * 100
end)
exports('setMicClickOnVolume', function(vol)
setVolume(vol, 'click_on')
end)
exports('getMicClickOnVolume', function()
return volumes['click_on'] * 100
end)
exports('setMicClickOffVolume', function(vol)
setVolume(vol, 'click_off')
end)
exports('getMicClickOffVolume', function()
return volumes['click_off'] * 100
end)
-- default submix incase people want to fiddle with it.
-- freq_low = 389.0
-- freq_hi = 3248.0
-- fudge = 0.0
-- rm_mod_freq = 0.0
-- rm_mix = 0.16
-- o_freq_lo = 348.0
-- o_freq_hi = 4900.0
local radioEffectId = CreateAudioSubmix('Radio')
SetAudioSubmixEffectRadioFx(radioEffectId, 0)
-- This is a GetHashKey on purpose, backticks break treesitter in nvim :|
SetAudioSubmixEffectParamInt(radioEffectId, 0, GetHashKey('default'), 1)
SetAudioSubmixOutputVolumes(
radioEffectId,
0,
1.0 --[[ frontLeftVolume ]],
0.25 --[[ frontRightVolume ]],
0.0 --[[ rearLeftVolume ]],
0.0 --[[ rearRightVolume ]],
1.0 --[[ channel5Volume ]],
1.0 --[[ channel6Volume ]]
)
AddAudioSubmixOutput(radioEffectId, 0)
submixIndicies['radio'] = radioEffectId
local callEffectId = CreateAudioSubmix('Call')
SetAudioSubmixOutputVolumes(
callEffectId,
1,
0.10 --[[ frontLeftVolume ]],
0.50 --[[ frontRightVolume ]],
0.0 --[[ rearLeftVolume ]],
0.0 --[[ rearRightVolume ]],
1.0 --[[ channel5Volume ]],
1.0 --[[ channel6Volume ]]
)
AddAudioSubmixOutput(callEffectId, 1)
submixIndicies['call'] = callEffectId
-- Callback is expected to return data in an array, this is for compatibility sake with js, index 0 should be the name and index 1 should be the submixId
-- the callback is sent the effectSlot it can register to, not sure if this is needed, but its here for safety
exports("registerCustomSubmix", function(callback)
local submixTable = callback()
type_check({ submixTable, "table" })
local submixName, submixId = submixTable[1], submixTable[2]
type_check({ submixName, "string" }, { submixId, "number" })
logger.info("Creating submix %s with submixId %s", submixName, submixId)
submixIndicies[submixName] = submixId
end)
TriggerEvent("pma-voice:registerCustomSubmixes")
--- export setEffectSubmix
--- Sets a user defined audio submix for radio and phonecall effects
---@param type string either "call" or "radio"
---@param effectId number submix id returned from CREATE_AUDIO_SUBMIX
exports("setEffectSubmix", function(type, effectId)
type_check({ type, "string" }, { effectId, "number" })
if submixIndicies[type] then
submixIndicies[type] = effectId
end
end)
function restoreDefaultSubmix(plyServerId)
local submix = Player(plyServerId).state.submix
local submixEffect = submixIndicies[submix]
if not submix or not submixEffect then
MumbleSetSubmixForServerId(plyServerId, -1)
return
end
MumbleSetSubmixForServerId(plyServerId, submixEffect)
end
-- used to prevent a race condition if they talk again afterwards, which would lead to their voice going to default.
local disableSubmixReset = {}
--- function toggleVoice
--- Toggles the players voice
---@param plySource number the players server id to override the volume for
---@param enabled boolean if the players voice is getting activated or deactivated
---@param moduleType string the volume & submix to use for the voice.
function toggleVoice(plySource, enabled, moduleType)
if mutedPlayers[plySource] then return end
logger.verbose('[main] Updating %s to talking: %s with submix %s', plySource, enabled, moduleType)
local distance = currentTargets[plySource]
if enabled and (not distance or distance > 4.0) then
print(volumes[moduleType])
MumbleSetVolumeOverrideByServerId(plySource, enabled and volumes[moduleType])
if GetConvarInt('voice_enableSubmix', 1) == 1 then
if moduleType then
disableSubmixReset[plySource] = true
if submixIndicies[moduleType] then
MumbleSetSubmixForServerId(plySource, submixIndicies[moduleType])
end
else
restoreDefaultSubmix(plySource)
end
end
elseif not enabled then
if GetConvarInt('voice_enableSubmix', 1) == 1 then
-- garbage collect it
disableSubmixReset[plySource] = nil
SetTimeout(250, function()
if not disableSubmixReset[plySource] then
restoreDefaultSubmix(plySource)
end
end)
end
MumbleSetVolumeOverrideByServerId(plySource, -1.0)
end
end
local function updateVolumes(voiceTable, override)
for serverId, talking in pairs(voiceTable) do
if serverId == playerServerId then goto skip_iter end
MumbleSetVolumeOverrideByServerId(serverId, talking and override or -1.0)
::skip_iter::
end
end
--- resyncs the call/radio/etc volume to the new volume
---@param volumeType any
function resyncVolume(volumeType, newVolume)
if volumeType == "all" then
resyncVolume("radio", newVolume)
resyncVolume("call", newVolume)
elseif volumeType == "radio" then
updateVolumes(radioData, newVolume)
elseif volumeType == "call" then
updateVolumes(callData, newVolume)
end
end
---Adds players voices to the local players listen channels allowing them to
---communicate at long range, ignoring proximity range.
---
---@diagnostic disable-next-line: undefined-doc-param
---@param targets table expects multiple tables to be sent over
function addVoiceTargets(...)
local targets = { ... }
local addedPlayers = {
[playerServerId] = true
}
for i = 1, #targets do
for id, _ in pairs(targets[i]) do
-- we don't want to log ourself, or listen to ourself
if addedPlayers[id] and id ~= playerServerId then
logger.verbose('[main] %s is already target don\'t re-add', id)
goto skip_loop
end
if not addedPlayers[id] then
logger.verbose('[main] Adding %s as a voice target', id)
addedPlayers[id] = true
MumbleAddVoiceTargetPlayerByServerId(voiceTarget, id)
end
::skip_loop::
end
end
end
--- function playMicClicks
---plays the mic click if the player has them enabled.
---@param clickType boolean whether to play the 'on' or 'off' click.
function playMicClicks(clickType)
if micClicks ~= true then return logger.verbose("Not playing mic clicks because client has them disabled") end
-- TODO: Add customizable radio click volumes
sendUIMessage({
sound = (clickType and "audio_on" or "audio_off"),
volume = (clickType and volumes['click_on'] or volumes['click_off'])
})
end
--- check if player is muted
exports('isPlayerMuted', function(source)
return mutedPlayers[source]
end)
--- getter for mutedPlayers
exports('getMutedPlayers', function()
return mutedPlayers
end)
--- toggles the targeted player muted
---@param source number the player to mute
function toggleMutePlayer(source)
if mutedPlayers[source] then
mutedPlayers[source] = nil
MumbleSetVolumeOverrideByServerId(source, -1.0)
else
mutedPlayers[source] = true
MumbleSetVolumeOverrideByServerId(source, 0.0)
end
end
exports('toggleMutePlayer', toggleMutePlayer)
--- function setVoiceProperty
--- sets the specified voice property
---@param type string what voice property you want to change (only takes 'radioEnabled' and 'micClicks')
---@param value any the value to set the type to.
function setVoiceProperty(type, value)
if type == "radioEnabled" then
radioEnabled = value
handleRadioEnabledChanged(value)
sendUIMessage({
radioEnabled = value
})
elseif type == "micClicks" then
micClicks = value == true or value == "true"
SetResourceKvp('pma-voice_enableMicClicks', tostring(micClicks))
end
end
exports('setVoiceProperty', setVoiceProperty)
-- compatibility
exports('SetMumbleProperty', setVoiceProperty)
exports('SetTokoProperty', setVoiceProperty)
-- cache their external servers so if it changes in runtime we can reconnect the client.
local externalAddress = ''
local externalPort = 0
CreateThread(function()
while true do
Wait(500)
-- only change if what we have doesn't match the cache
if GetConvar('voice_externalAddress', '') ~= externalAddress or GetConvarInt('voice_externalPort', 0) ~= externalPort then
externalAddress = GetConvar('voice_externalAddress', '')
externalPort = GetConvarInt('voice_externalPort', 0)
MumbleSetServerAddress(GetConvar('voice_externalAddress', ''), GetConvarInt('voice_externalPort', 0))
end
end
end)
if gameVersion == 'redm' then
function on_key_up() end
local KEY_F11 = 0x7A
RegisterRawKeymap("pma-voice_proximityCycle", on_key_up, function()
ExecuteCommand('cycleproximity')
end, KEY_F11, true)
local KEY_LEFT_ALT = 0xA4
RegisterRawKeymap("pma-voice_radioTalk", function()
ExecuteCommand('+radiotalk')
end, function()
ExecuteCommand('-radiotalk')
end, KEY_LEFT_ALT, true)
end
--- handles initializiation for whenever radio or call data changes
--- calls should always be last because they're assumed to always be enabled so
--- theres no delay in talking.
function handleRadioAndCallInit()
for tgt, enabled in pairs(radioData) do
if tgt ~= playerServerId then
toggleVoice(tgt, enabled, 'radio')
end
end
for tgt, enabled in pairs(callData) do
if tgt ~= playerServerId then
toggleVoice(tgt, true, 'call')
end
end
end

View File

@@ -0,0 +1,253 @@
-- used when muted
local disableUpdates = false
local isListenerEnabled = false
local plyCoords = GetEntityCoords(PlayerPedId())
proximity = MumbleGetTalkerProximity()
currentTargets = {}
-- a list of all the players the current client us listening to
-- the value will be set to false if we didn't actually start listening to them (in situations where their channel didn't exist)
-- TODO: PR a native to let us get if we're listening to a certain channel.
local listeners = {}
function orig_addProximityCheck(ply)
local tgtPed = GetPlayerPed(ply)
local voiceRange = GetConvar('voice_useNativeAudio', 'false') == 'true' and proximity * 3 or proximity
local distance = #(plyCoords - GetEntityCoords(tgtPed))
return distance < voiceRange, distance
end
local addProximityCheck = orig_addProximityCheck
exports("overrideProximityCheck", function(fn)
addProximityCheck = fn
end)
exports("resetProximityCheck", function()
addProximityCheck = orig_addProximityCheck
end)
function addNearbyPlayers()
if disableUpdates then return end
-- update here so we don't have to update every call of addProximityCheck
plyCoords = GetEntityCoords(PlayerPedId())
proximity = MumbleGetTalkerProximity()
currentTargets = {}
MumbleClearVoiceTargetChannels(voiceTarget)
if LocalPlayer.state.disableProximity then return end
MumbleAddVoiceChannelListen(LocalPlayer.state.assignedChannel)
MumbleAddVoiceTargetChannel(voiceTarget, LocalPlayer.state.assignedChannel)
for source, _ in pairs(callData) do
if source ~= playerServerId then
local channel = MumbleGetVoiceChannelFromServerId(source)
if channel ~= -1 then
MumbleAddVoiceTargetChannel(voiceTarget, channel)
end
end
end
local players = GetActivePlayers()
for i = 1, #players do
local ply = players[i]
local serverId = GetPlayerServerId(ply)
local shouldAdd, distance = addProximityCheck(ply)
if shouldAdd then
-- if distance then
-- currentTargets[serverId] = distance
-- else
-- -- backwards compat, maybe remove in v7
-- currentTargets[serverId] = 15.0
-- end
-- logger.verbose('Added %s as a voice target', serverId)
local channel = MumbleGetVoiceChannelFromServerId(serverId)
if channel ~= -1 then
MumbleAddVoiceTargetChannel(voiceTarget, channel)
end
end
end
end
function addChannelListener(serverId)
-- not in the documentation, but this will return -1 whenever the client isn't in a channel
local channel = MumbleGetVoiceChannelFromServerId(serverId)
if channel ~= -1 then
MumbleAddVoiceChannelListen(channel)
logger.verbose("Adding %s to listen table", serverId)
end
listeners[serverId] = channel ~= -1
end
function removeChannelListener(serverId)
if listeners[serverId] then
local channel = MumbleGetVoiceChannelFromServerId(serverId)
if channel ~= -1 then
MumbleRemoveVoiceChannelListen(channel)
end
logger.verbose("Removing %s from listen table", serverId)
end
-- remove the listener if they exist
listeners[serverId] = nil
end
function setSpectatorMode(enabled)
logger.info('Setting spectate mode to %s', enabled)
isListenerEnabled = enabled
local players = GetActivePlayers()
if isListenerEnabled then
for i = 1, #players do
local ply = players[i]
local serverId = GetPlayerServerId(ply)
if serverId == playerServerId then goto skip_loop end
addChannelListener(serverId)
::skip_loop::
end
else
for i = 1, #players do
local ply = players[i]
local serverId = GetPlayerServerId(ply)
if serverId == playerServerId then goto skip_loop end
removeChannelListener(serverId)
::skip_loop::
end
-- cleanup table if we stop listening
listeners = {}
end
end
function tryListeningToFailedListeners()
for src, isListening in pairs(listeners) do
-- if we failed to listen before we'll be set to false
if not isListening then
addChannelListener(src)
end
end
end
RegisterNetEvent('onPlayerJoining', function(serverId)
if isListenerEnabled then
addChannelListener(serverId)
end
end)
RegisterNetEvent('onPlayerDropped', function(serverId)
if isListenerEnabled then
removeChannelListener(serverId)
end
end)
local listenerOverride = false
exports("setListenerOverride", function(enabled)
type_check({ enabled, "boolean" })
listenerOverride = enabled
end)
-- cache talking status so we only send a nui message when its not the same as what it was before
local lastTalkingStatus = false
local lastRadioStatus = false
local voiceState = "proximity"
CreateThread(function()
TriggerEvent('chat:addSuggestion', '/muteply', 'Mutes the player with the specified id', {
{ name = "player id", help = "the player to toggle mute" },
{ name = "duration", help = "(opt) the duration the mute in seconds (default: 900)" }
})
while true do
-- wait for mumble to reconnect
while not MumbleIsConnected() or not isInitialized do
Wait(100)
end
-- Leave the check here as we don't want to do any of this logic
if GetConvarInt('voice_enableUi', 1) == 1 then
local curTalkingStatus = MumbleIsPlayerTalking(PlayerId()) == 1
if lastRadioStatus ~= radioPressed or lastTalkingStatus ~= curTalkingStatus then
lastRadioStatus = radioPressed
lastTalkingStatus = curTalkingStatus
sendUIMessage({
usingRadio = lastRadioStatus,
talking = lastTalkingStatus
})
end
end
if voiceState == "proximity" then
addNearbyPlayers()
-- What a name, wowza
local cam = GetConvarInt("voice_disableAutomaticListenerOnCamera", 0) ~= 1 and GetRenderingCam() or -1
local isSpectating = NetworkIsInSpectatorMode() or cam ~= -1
if not isListenerEnabled and (isSpectating or listenerOverride) then
setSpectatorMode(true)
elseif isListenerEnabled and not isSpectating and not listenerOverride then
setSpectatorMode(false)
end
tryListeningToFailedListeners()
end
Wait(GetConvarInt('voice_refreshRate', 200))
end
end)
exports("setVoiceState", function(_voiceState, channel)
if _voiceState ~= "proximity" and _voiceState ~= "channel" then
logger.error("Didn't get a proper voice state, expected proximity or channel, got %s", _voiceState)
end
voiceState = _voiceState
if voiceState == "channel" then
type_check({ channel, "number" })
-- 65535 is the highest a client id can go, so we add that to the base channel so we don't manage to get onto a players channel
channel = channel + 65535
MumbleSetVoiceChannel(channel)
while MumbleGetVoiceChannelFromServerId(playerServerId) ~= channel do
Wait(250)
end
MumbleAddVoiceTargetChannel(voiceTarget, channel)
elseif voiceState == "proximity" then
handleInitialState()
end
end)
AddEventHandler("onClientResourceStop", function(resource)
if type(addProximityCheck) == "table" then
local proximityCheckRef = addProximityCheck.__cfx_functionReference
if proximityCheckRef then
local isResource = string.match(proximityCheckRef, resource)
if isResource then
addProximityCheck = orig_addProximityCheck
logger.warn(
'Reset proximity check to default, the original resource [%s] which provided the function restarted',
resource)
end
end
end
end)
exports("addVoiceMode", function(distance, name)
for i = 1, #Cfg.voiceModes do
local voiceMode = Cfg.voiceModes[i]
if voiceMode[2] == name then
logger.verbose("Already had %s, overwritting instead", name)
voiceMode[1] = distance
return
end
end
Cfg.voiceModes[#Cfg.voiceModes + 1] = { distance, name }
end)
exports("removeVoiceMode", function(name)
for i = 1, #Cfg.voiceModes do
local voiceMode = Cfg.voiceModes[i]
if voiceMode[2] == name then
table.remove(Cfg.voiceModes, i)
-- Reset our current range if we had it
if mode == i then
local newMode = Cfg.voiceModes[1]
mode = 1
setProximityState(newMode[mode], false)
end
return true
end
end
return false
end)

View File

@@ -0,0 +1,27 @@
AddStateBagChangeHandler("submix", "", function(bagName, _, value)
local tgtId = tonumber(bagName:gsub('player:', ''), 10)
if not tgtId then return end
-- We got an invalid submix, discard we don't care about it
if value and not submixIndicies[value] then
return logger.warn("Player %s applied submix %s but it isn't valid",
tgtId, value)
end
-- we don't want to reset submix if the player is talking on the radio
if not value then
if not radioData[tgtId] and not callData[tgtId] then
logger.info("Resetting submix for player %s", tgtId)
MumbleSetSubmixForServerId(tgtId, -1)
end
return
end
logger.info("%s had their submix set to %s", tgtId, value)
MumbleSetSubmixForServerId(tgtId, submixIndicies[value])
end)
RegisterNetEvent("onPlayerDropped", function(tgtId)
if not radioData[tgtId] and not callData[tgtId] then
logger.info("Resetting submix for player %s", tgtId)
MumbleSetSubmixForServerId(tgtId, -1)
end
end)