Saltychat Remove and PMA install

This commit is contained in:
Miho931 2025-06-30 19:26:56 +02:00
parent 0bff8ae174
commit 2fd3c1fe70
94 changed files with 8799 additions and 5199 deletions

View file

@ -0,0 +1,21 @@
MIT License

Copyright (c) 2021 Dillon Skaggs

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,180 @@
# pma-voice
A voice system designed around the use of FiveM/RedM internal mumble server.

## Support

Please report any issues you have in the GitHub [Issues](https://github.com/AvarianKnight/pma-voice/issues)

### NOTE: It is expected for servers to be on the latest recommended version, which you can find [here for Windows](https://runtime.fivem.net/artifacts/fivem/build_server_windows/master/) and [here for Linux](https://runtime.fivem.net/artifacts/fivem/build_proot_linux/master/).

# Compatibility Notice:

This script is not compatible with other voice systems (duh), that means if you have vMenus voice chat you will **have** to [disable](https://docs.vespura.com/vmenu/faq/#q-how-do-i-disable-voice-chat) it.

Please do not override `NetworkSetTalkerProximity`, `MumbleSetAudioInputDistance`, `MumbleSetAudioOutputDistance` or `NetworkSetVoiceActive` in any of your other scripts as there have been cases where it breaks pma-voice.

# Credits

- @Frazzle for mumble-voip (for which the concept came from)
- @pichotm for pVoice (where the grid concept came from)

# FiveM/RedM Config

### NOTE: Only use one of the Audio options (don't enable 3d Audio & Native Audio at the same time), its also recommended to always use voice_useSendingRangeOnly.

You only need to add the convar **if** you're changing the value.

All of the configs here are set using `setr [voice_configOption] [boolean]`

Native audio will not work on RedM, you will have to use 3d audio.

| ConVar | Default | Description | Parameter(s) |
|----------------------------|---------|---------------------------------------------------------------|--------------|
| voice_useNativeAudio | false | **This will not work for RedM** Uses the games native audio, will add 3d sound, echo, reverb, and more. **Required for submixs** | boolean |
| voice_use2dAudio | false | Uses 2d audio, will result in same volume sound no matter where they're at until they leave proximity. | boolean
| voice_use3dAudio | false | Uses 3d audio | boolean |
| voice_useSendingRangeOnly | false | Only allows you to hear people within your hear/send range, prevents people from connecting to your mumble server and trolling. | boolean |

# Config

### PLEASE NOTE: Any keybind changes only affect new players, if you want to change your key bind go to Key Bindings -> FiveM -> Look for keybinds under 'pma-voice'.

All of the config is done via ConVars in order to streamline the process.

The ints are used like a boolean to 0 would be false, 1 true.

All of the configs here are set using `setr [voice_configOption] [int]` OR `setr [voice_configOption] "[string]"`

#### Note: If a convar defaults to 1 (true) you don't have set it again unless you want to disable it.

### General Voice Settings

| ConVar | Default | Description | Parameter(s) |
|-------------------------|---------|--------------------------------------------------------------------|--------------|
| voice_enableUi | 1 | Enables the built in user interface | int |
| voice_enableProximityCycle | 1 | Enables the usage of the F11 proximity key, if disabled players are stuck on the first proximity | int |
| voice_defaultCycle | F11 | The default key to cycle the players proximity. You can find a list of valid keys [in the Cfx docs](https://docs.fivem.net/docs/game-references/input-mapper-parameter-ids/keyboard/) | string |
| voice_defaultRadioVolume | 30 | The default volume to set the radio to (has to be between 1 and 100) *NOTE: Only new joins will have the new value, players that already joined will not.* | float |
| voice_defaultPhoneVolume | 60 | The default volume to set the phone to (has to be between 1 and 100) *NOTE: Only new joins will have the new value, players that already joined will not.* | float |
| voice_defaultVoiceMode | 2 | Default proximity voice value when player joins server. (Voice Modes; 1:Whisper, 2:Normal, 3:Shouting) | int |

### Phone & Radio

| ConVar | Default | Description | Parameter(s) |
|-------------------------|---------|--------------------------------------------------------------------|--------------|
| voice_enableRadios | 1 | Enables the radio sub-modules | int |
| voice_enablePhones | 1 | Enables the phone sub-modules | int |
| voice_enableSubmix | 1 | Enables the submix which adds a radio/phone style submix to their voice **NOTE: Submixs require native audio** | int |
| voice_enableRadioAnim | 0 | Enables (grab shoulder mic) animation while talking on the radio. | int |
| voice_defaultRadio | LMENU | The default key to use the radio. You can find a list of valid keys [in the FiveM docs](https://docs.fivem.net/docs/game-references/input-mapper-parameter-ids/keyboard/) | string |

### Sync

| ConVar | Default | Description | Parameter(s) |
|-------------------------|---------|--------------------------------------------------------------------|--------------|
| voice_refreshRate | 200 | How often the UI/Proximity is refreshed | int |

### External Server & Misc.
| ConVar | Default | Description | Parameter(s) |
|-------------------------|---------|--------------------------------------------------------------------|--------------|
| voice_allowSetIntent | 1 | Whether or not to allow players to set their audio intents (you can see more [here](https://docs.fivem.net/natives/?_0x6383526B)) | int |
| voice_externalAddress | none | The external address to use to connect to the mumble server | string |
| voice_externalPort | 0 | The external port to use | int |
| voice_debugMode | 0 | 1 for basic logs, 4 for verbose logs | int |
| voice_externalDisallowJoin | 0 | Disables players being allowed to join the server, should only be used if you're using a FXServer as a external mumble server. | int |
| voice_hideEndpoints | 1 | Hides the mumble address in logs *NOTE: You should only care to hide this for a external server.* | int |



### Aces

pma-voice comes with a built in /muteply (tgtPly) (duration) command, in order to allow your staff to use it you will have to grand them the ace!

Example:
`add_ace group.superadmin command.muteply allow;`

This would only allow the superadmin group to mute players.

### Exports

#### Client

##### Setters
| Export | Description | Parameter(s) |
|---------------------|-----------------------------|--------------|
| [setVoiceProperty](docs/client-setters/setVoiceProperty.md) | Set config options | string, any |
| [setRadioChannel](docs/client-setters/setRadioChannel.md) | Set radio channel | int |
| [setCallChannel](docs/client-setters/setCallChannel.md) | Set call channel | int |
| [setRadioVolume](docs/client-setters/setRadioVolume.md) | Set radio volume for player | int |
| [setCallVolume](docs/client-setters/setCallVolume.md) | Set call volume for player | int |
| [addPlayerToRadio](docs/client-setters/setRadioChannel.md) | Set radio channel | int |
| [addPlayerToCall](docs/client-setters/setCallChannel.md) | Set call channel | int |
| [removePlayerFromRadio](docs/client-setters/removePlayerFromRadio.md) | Remove player from radio | |
| [removePlayerFromCall](docs/client-setters/removePlayerFromCall.md) | Remove player from call | |

##### Toggles

| Export | Description | Parameter(s) |
|---------------------|--------------------------------------------------------|--------------|
| toggleMutePlayer | Toggles the selected player muted for the local client | int |

Supported from mumble-voip / toko-voip

| Export | Description | Parameter(s) |
|-----------------------|--------------------------|--------------|
| [SetMumbleProperty](docs/client-setters/setVoiceProperty.md) | Set config options | string, any |
| [SetTokoProperty](docs/client-setters/setVoiceProperty.md) | Set config options | string, any |
| [SetRadioChannel](docs/client-setters/setRadioChannel.md) | Set radio channel | int |
| [SetCallChannel](docs/client-setters/setCallChannel.md) | Set call channel | int |

#### Getters

The majority of setters are done through player states, while a small


| State Bag | Description | Return Type |
|---------------|--------------------------------------------------------------|--------------|
| [proximity](docs/state-getters/stateBagGetters.md) | Returns a table with the mode index, distance, and mode name | table |
| [radioChannel](docs/state-getters/stateBagGetters.md) | Returns the players current radio channel, or 0 for none | int |
| [callChannel](docs/state-getters/stateBagGetters.md) | Returns the players current call channel, or 0 for none | int |

#### Events

These are events designed for third-party resource integration. These are emitted only to the current client.

| Event | Description | Event Params |
|--------------------------|--------------------------------------------------------------|----------------|
| [pma-voice:settingsCallback](docs/client-getters/events.md) | When emited it will return the current pma-voice settings. | cb(voiceSettings) |
| [pma-voice:radioActive](docs/client-getters/events.md) | Triggered when the radio is activated / deactivated | boolean |
| [pma-voice:setTalkingMode](docs/client-getters/events.md) | Triggered on proximity mode change with the voice mode id | int |


#### Server

##### Setters

| Export | Description | Parameter(s) |
|----------------------|--------------------------------------|--------------|
| [setPlayerRadio](docs/server-setters/setPlayerRadio.md) | Sets the players radio channel | int, int |
| [setPlayerCall](docs/server-setters/setPlayerCall.md) | Sets the players call channel | int, int |
| [addChannelCheck](docs/server-setters/addChannelCheck.md) | Adds a channel check to the players radio channel | int, function |


##### Getters

###### State Bags
You can access the state with `Player(source).state['state bag here']`

| State Bag | Description | Return Type |
|---------------|--------------------------------------------------------------|--------------|
| [proximity](docs/state-getters/stateBagGetters.md) | Returns a table with the mode index, distance, and mode name | table |
| [radioChannel](docs/state-getters/stateBagGetters.md) | Returns the players current radio channel, or 0 for none | int |
| [callChannel](docs/state-getters/stateBagGetters.md) | Returns the players current call channel, or 0 for none | int |
| [voiceIntent](docs/state-getters/stateBagGetters.md) | Returns the players current voice intent, either 'speech' or 'music' | string |

###### Exports

| Export | Description | Parameter(s) |
|------------------------------|---------------------------------------------------|------|
| [getPlayersInRadioChannel](docs/server-getters/getPlayersInRadioChannel.md) | Gets the current players in a radio channel | int |

View file

@ -0,0 +1,10 @@
## TODO
- [ ] Rename everything that uses 'phone' to 'call' for consistency.
- [ ] Ability to display radio members on the client
- [ ] Use commands to define voiceModes in shared.lua and only leave debug logs in shared.lua
- [ ] Convert the UI to React.
- [ ] Multiple radio channels

## DONE
- [ x ] Implement a easy way to get the players current radio channel on the server
- [ x ] Add the ability to override proximity with exports

View file

@ -0,0 +1,74 @@
local wasProximityDisabledFromOverride = false
disableProximityCycle = false
RegisterCommand('setvoiceintent', function(source, args)
if GetConvarInt('voice_allowSetIntent', 1) == 1 then
local intent = args[1]
if intent == 'speech' then
MumbleSetAudioInputIntent(`speech`)
elseif intent == 'music' then
MumbleSetAudioInputIntent(`music`)
end
LocalPlayer.state:set('voiceIntent', intent, true)
end
end)

-- TODO: Better implementation of this?
RegisterCommand('vol', function(_, args)
if not args[1] then return end
setVolume(tonumber(args[1]))
end)

exports('setAllowProximityCycleState', function(state)
type_check({state, "boolean"})
disableProximityCycle = state
end)

function setProximityState(proximityRange, isCustom)
local voiceModeData = Cfg.voiceModes[mode]
MumbleSetTalkerProximity(proximityRange + 0.0)
LocalPlayer.state:set('proximity', {
index = mode,
distance = proximityRange,
mode = isCustom and "Custom" or voiceModeData[2],
}, true)
sendUIMessage({
-- JS expects this value to be - 1, "custom" voice is on the last index
voiceMode = isCustom and #Cfg.voiceModes or mode - 1
})
end

exports("overrideProximityRange", function(range, disableCycle)
type_check({range, "number"})
setProximityState(range, true)
if disableCycle then
disableProximityCycle = true
wasProximityDisabledFromOverride = true
end
end)

exports("clearProximityOverride", function()
local voiceModeData = Cfg.voiceModes[mode]
setProximityState(voiceModeData[1], false)
if wasProximityDisabledFromOverride then
disableProximityCycle = false
end
end)

RegisterCommand('cycleproximity', function()
-- Proximity is either disabled, or manually overwritten.
if GetConvarInt('voice_enableProximityCycle', 1) ~= 1 or disableProximityCycle then return end
local newMode = mode + 1

-- If we're within the range of our voice modes, allow the increase, otherwise reset to the first state
if newMode <= #Cfg.voiceModes then
mode = newMode
else
mode = 1
end

setProximityState(Cfg.voiceModes[mode][1], false)
TriggerEvent('pma-voice:setTalkingMode', mode)
end, false)
if gameVersion == 'fivem' then
RegisterKeyMapping('cycleproximity', 'Cycle Proximity', 'keyboard', GetConvar('voice_defaultCycle', 'F11'))
end

View file

@ -0,0 +1,41 @@
function handleInitialState()
local voiceModeData = Cfg.voiceModes[mode]
MumbleSetTalkerProximity(voiceModeData[1] + 0.0)
MumbleClearVoiceTarget(voiceTarget)
MumbleSetVoiceTarget(voiceTarget)
MumbleSetVoiceChannel(playerServerId)

while MumbleGetVoiceChannelFromServerId(playerServerId) ~= playerServerId do
Wait(250)
end

MumbleAddVoiceTargetChannel(voiceTarget, playerServerId)

addNearbyPlayers()
end

AddEventHandler('mumbleConnected', function(address, isReconnecting)
logger.info('Connected to mumble server with address of %s, is this a reconnect %s', GetConvarInt('voice_hideEndpoints', 1) == 1 and 'HIDDEN' or address, isReconnecting)

logger.log('Connecting to mumble, setting targets.')
-- don't try to set channel instantly, we're still getting data.
local voiceModeData = Cfg.voiceModes[mode]
LocalPlayer.state:set('proximity', {
index = mode,
distance = voiceModeData[1],
mode = voiceModeData[2],
}, true)

handleInitialState()

logger.log('Finished connection logic')
end)

AddEventHandler('mumbleDisconnected', function(address)
logger.info('Disconnected from mumble server with address of %s', GetConvarInt('voice_hideEndpoints', 1) == 1 and 'HIDDEN' or address)
end)

-- TODO: Convert the last Cfg to a Convar, while still keeping it simple.
AddEventHandler('pma-voice:settingsCallback', function(cb)
cb(Cfg)
end)

View file

@ -0,0 +1,42 @@

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', tostring(true))
else
if micClicksKvp ~= 'true' and micClicksKvp ~= 'false' then
error('Invalid Kvp, throwing error for automatic cleaning')
end
micClicks = micClicksKvp
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', tostring(true))
micClicks = 'true'
end
sendUIMessage({
uiEnabled = GetConvarInt("voice_enableUi", 1) == 1,
voiceModes = json.encode(Cfg.voiceModes),
voiceMode = mode - 1
})

-- Reinitialize channels if they're set.
if LocalPlayer.state.radioChannel ~= 0 then
setRadioChannel(LocalPlayer.state.radioChannel)
end

if LocalPlayer.state.callChannel ~= 0 then
setCallChannel(LocalPlayer.state.callChannel)
end
print('Script initialization finished.')
end)

View file

@ -0,0 +1,224 @@
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', 30) / 100,
['phone'] = GetConvarInt('voice_defaultPhoneVolume', 60) / 100,
}

radioEnabled, radioPressed, mode = true, false, GetConvarInt('voice_defaultVoiceMode', 2)
radioData = {}
callData = {}

--- 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 volume = volume / 100
if volumeType then
local volumeTbl = volumes[volumeType]
if volumeTbl then
LocalPlayer.state:set(volumeType, volume, true)
volumes[volumeType] = volume
else
error(('setVolume got a invalid volume type %s'):format(volumeType))
end
else
-- _ is here to not mess with global 'type' function
for _type, vol in pairs(volumes) do
volumes[_type] = volume
LocalPlayer.state:set(_type, volume, true)
end
end
end

exports('setRadioVolume', function(vol)
setVolume(vol, 'radio')
end)
exports('getRadioVolume', function()
return volumes['radio']
end)
exports("setCallVolume", function(vol)
setVolume(vol, 'phone')
end)
exports('getCallVolume', function()
return volumes['phone']
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
-- 0_freq_hi = 4900.0

if gameVersion == 'fivem' then
radioEffectId = CreateAudioSubmix('Radio')
SetAudioSubmixEffectRadioFx(radioEffectId, 0)
SetAudioSubmixEffectParamInt(radioEffectId, 0, `default`, 1)
AddAudioSubmixOutput(radioEffectId, 0)

phoneEffectId = CreateAudioSubmix('Phone')
SetAudioSubmixEffectRadioFx(phoneEffectId, 1)
SetAudioSubmixEffectParamInt(phoneEffectId, 1, `default`, 1)
SetAudioSubmixEffectParamFloat(phoneEffectId, 1, `freq_low`, 300.0)
SetAudioSubmixEffectParamFloat(phoneEffectId, 1, `freq_hi`, 6000.0)
AddAudioSubmixOutput(phoneEffectId, 1)
end

local submixFunctions = {
['radio'] = function(plySource)
MumbleSetSubmixForServerId(plySource, radioEffectId)
end,
['phone'] = function(plySource)
MumbleSetSubmixForServerId(plySource, phoneEffectId)
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)
if enabled then
MumbleSetVolumeOverrideByServerId(plySource, enabled and volumes[moduleType])
if GetConvarInt('voice_enableSubmix', 1) == 1 and gameVersion == 'fivem' then
if moduleType then
disableSubmixReset[plySource] = true
submixFunctions[moduleType](plySource)
else
MumbleSetSubmixForServerId(plySource, -1)
end
end
else
if GetConvarInt('voice_enableSubmix', 1) == 1 and gameVersion == 'fivem' then
-- garbage collect it
disableSubmixReset[plySource] = nil
SetTimeout(250, function()
if not disableSubmixReset[plySource] then
MumbleSetSubmixForServerId(plySource, -1)
end
end)
end
MumbleSetVolumeOverrideByServerId(plySource, -1.0)
end
end

--- function playerTargets
---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 playerTargets(...)
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
sendUIMessage({
sound = (clickType and "audio_on" or "audio_off"),
volume = (clickType and volumes["radio"] or 0.05)
})
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
sendUIMessage({
radioEnabled = value
})
elseif type == "micClicks" then
local val = tostring(value)
micClicks = val
SetResourceKvp('pma-voice_enableMicClicks', val)
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
CreateThread(function()
while true do
if IsControlJustPressed(0, 0xA5BDCD3C --[[ Right Bracket ]]) then
ExecuteCommand('cycleproximity')
end
if IsControlJustPressed(0, 0x430593AA --[[ Left Bracket ]]) then
ExecuteCommand('+radiotalk')
elseif IsControlJustReleased(0, 0x430593AA --[[ Left Bracket ]]) then
ExecuteCommand('-radiotalk')
end

Wait(0)
end
end)
end

View file

@ -0,0 +1,156 @@
-- used when muted
local disableUpdates = false
local isListenerEnabled = false
local plyCoords = GetEntityCoords(PlayerPedId())

function orig_addProximityCheck(ply)
local tgtPed = GetPlayerPed(ply)
local voiceModeData = Cfg.voiceModes[mode]
local distance = GetConvar('voice_useNativeAudio', 'false') == 'true' and voiceModeData[1] * 3 or voiceModeData[1]

return #(plyCoords - GetEntityCoords(tgtPed)) < 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())

MumbleClearVoiceTargetChannels(voiceTarget)
local players = GetActivePlayers()
for i = 1, #players do
local ply = players[i]
local serverId = GetPlayerServerId(ply)

if addProximityCheck(ply) then
if isTarget then goto skip_loop end

logger.verbose('Added %s as a voice target', serverId)
MumbleAddVoiceTargetChannel(voiceTarget, serverId)
end

::skip_loop::
end
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
logger.verbose("Adding %s to listen table", serverId)
MumbleAddVoiceChannelListen(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
logger.verbose("Removing %s from listen table", serverId)
MumbleRemoveVoiceChannelListen(serverId)
::skip_loop::
end
end
end

RegisterNetEvent('onPlayerJoining', function(serverId)
if isListenerEnabled then
MumbleAddVoiceChannelListen(serverId)
logger.verbose("Adding %s to listen table", serverId)
end
end)

RegisterNetEvent('onPlayerDropped', function(serverId)
if isListenerEnabled then
MumbleRemoveVoiceChannelListen(serverId)
logger.verbose("Removing %s from listen table", serverId)
end
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"
Citizen.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() 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()
local isSpectating = NetworkIsInSpectatorMode()
if isSpectating and not isListenerEnabled then
setSpectatorMode(true)
elseif not isSpectating and isListenerEnabled then
setSpectatorMode(false)
end
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)

View file

@ -0,0 +1,91 @@
local callChannel = 0

---function createPhoneThread
---creates a phone thread to listen for key presses
local function createPhoneThread()
Citizen.CreateThread(function()
local changed = false
while callChannel ~= 0 do
-- check if they're pressing voice keybinds
if MumbleIsPlayerTalking(PlayerId()) and not changed then
changed = true
playerTargets(radioPressed and radioData or {}, callData)
TriggerServerEvent('pma-voice:setTalkingOnCall', true)
elseif changed and MumbleIsPlayerTalking(PlayerId()) ~= 1 then
changed = false
MumbleClearVoiceTargetPlayers(voiceTarget)
TriggerServerEvent('pma-voice:setTalkingOnCall', false)
end
Wait(0)
end
end)
end

RegisterNetEvent('pma-voice:syncCallData', function(callTable, channel)
callData = callTable
for tgt, enabled in pairs(callTable) do
if tgt ~= playerServerId then
toggleVoice(tgt, enabled, 'phone')
end
end
end)

RegisterNetEvent('pma-voice:setTalkingOnCall', function(tgt, enabled)
if tgt ~= playerServerId then
callData[tgt] = enabled
toggleVoice(tgt, enabled, 'phone')
end
end)

RegisterNetEvent('pma-voice:addPlayerToCall', function(plySource)
callData[plySource] = false
end)

RegisterNetEvent('pma-voice:removePlayerFromCall', function(plySource)
if plySource == playerServerId then
for tgt, _ in pairs(callData) do
if tgt ~= playerServerId then
toggleVoice(tgt, false, 'phone')
end
end
callData = {}
MumbleClearVoiceTargetPlayers(voiceTarget)
playerTargets(radioPressed and radioData or {}, callData)
else
callData[plySource] = nil
toggleVoice(plySource, false, 'phone')
if MumbleIsPlayerTalking(PlayerId()) then
MumbleClearVoiceTargetPlayers(voiceTarget)
playerTargets(radioPressed and radioData or {}, callData)
end
end
end)

function setCallChannel(channel)
if GetConvarInt('voice_enablePhones', 1) ~= 1 then return end
TriggerServerEvent('pma-voice:setPlayerCall', channel)
callChannel = channel
sendUIMessage({
callInfo = channel
})
createPhoneThread()
end

exports('setCallChannel', setCallChannel)
exports('SetCallChannel', setCallChannel)

exports('addPlayerToCall', function(_call)
local call = tonumber(_call)
if call then
setCallChannel(call)
end
end)
exports('removePlayerFromCall', function()
setCallChannel(0)
end)

RegisterNetEvent('pma-voice:clSetPlayerCall', function(_callChannel)
if GetConvarInt('voice_enablePhones', 1) ~= 1 then return end
callChannel = _callChannel
createPhoneThread()
end)

View file

@ -0,0 +1,211 @@
local radioChannel = 0
local radioNames = {}
local disableRadioAnim = false

--- event syncRadioData
--- syncs the current players on the radio to the client
---@param radioTable table the table of the current players on the radio
---@param localPlyRadioName string the local players name
function syncRadioData(radioTable, localPlyRadioName)
radioData = radioTable
logger.info('[radio] Syncing radio table.')
if GetConvarInt('voice_debugMode', 0) >= 4 then
print('-------- RADIO TABLE --------')
tPrint(radioData)
print('-----------------------------')
end
for tgt, enabled in pairs(radioTable) do
if tgt ~= playerServerId then
toggleVoice(tgt, enabled, 'radio')
end
end
sendUIMessage({
radioChannel = radioChannel,
radioEnabled = radioEnabled
})
if GetConvarInt("voice_syncPlayerNames", 0) == 1 then
radioNames[playerServerId] = localPlyRadioName
end
end
RegisterNetEvent('pma-voice:syncRadioData', syncRadioData)

--- event setTalkingOnRadio
--- sets the players talking status, triggered when a player starts/stops talking.
---@param plySource number the players server id.
---@param enabled boolean whether the player is talking or not.
function setTalkingOnRadio(plySource, enabled)
toggleVoice(plySource, enabled, 'radio')
radioData[plySource] = enabled
playMicClicks(enabled)
end
RegisterNetEvent('pma-voice:setTalkingOnRadio', setTalkingOnRadio)

--- event addPlayerToRadio
--- adds a player onto the radio.
---@param plySource number the players server id to add to the radio.
function addPlayerToRadio(plySource, plyRadioName)
radioData[plySource] = false
if GetConvarInt("voice_syncPlayerNames", 0) == 1 then
radioNames[plySource] = plyRadioName
end
if radioPressed then
logger.info('[radio] %s joined radio %s while we were talking, adding them to targets', plySource, radioChannel)
playerTargets(radioData, MumbleIsPlayerTalking(PlayerId()) and callData or {})
else
logger.info('[radio] %s joined radio %s', plySource, radioChannel)
end
end
RegisterNetEvent('pma-voice:addPlayerToRadio', addPlayerToRadio)

--- event removePlayerFromRadio
--- removes the player (or self) from the radio
---@param plySource number the players server id to remove from the radio.
function removePlayerFromRadio(plySource)
if plySource == playerServerId then
logger.info('[radio] Left radio %s, cleaning up.', radioChannel)
for tgt, _ in pairs(radioData) do
if tgt ~= playerServerId then
toggleVoice(tgt, false, 'radio')
end
end
sendUIMessage({
radioChannel = 0,
radioEnabled = radioEnabled
})
radioNames = {}
radioData = {}
playerTargets(MumbleIsPlayerTalking(PlayerId()) and callData or {})
else
toggleVoice(plySource, false)
if radioPressed then
logger.info('[radio] %s left radio %s while we were talking, updating targets.', plySource, radioChannel)
playerTargets(radioData, MumbleIsPlayerTalking(PlayerId()) and callData or {})
else
logger.info('[radio] %s has left radio %s', plySource, radioChannel)
end
radioData[plySource] = nil
if GetConvarInt("voice_syncPlayerNames", 0) == 1 then
radioNames[plySource] = nil
end
end
end
RegisterNetEvent('pma-voice:removePlayerFromRadio', removePlayerFromRadio)

--- function setRadioChannel
--- sets the local players current radio channel and updates the server
---@param channel number the channel to set the player to, or 0 to remove them.
function setRadioChannel(channel)
if GetConvarInt('voice_enableRadios', 1) ~= 1 then return end
type_check({channel, "number"})
TriggerServerEvent('pma-voice:setPlayerRadio', channel)
radioChannel = channel
end

--- exports setRadioChannel
--- sets the local players current radio channel and updates the server
---@param channel number the channel to set the player to, or 0 to remove them.
exports('setRadioChannel', setRadioChannel)
-- mumble-voip compatability
exports('SetRadioChannel', setRadioChannel)

--- exports removePlayerFromRadio
--- sets the local players current radio channel and updates the server
exports('removePlayerFromRadio', function()
setRadioChannel(0)
end)

--- exports addPlayerToRadio
--- sets the local players current radio channel and updates the server
---@param _radio number the channel to set the player to, or 0 to remove them.
exports('addPlayerToRadio', function(_radio)
local radio = tonumber(_radio)
if radio then
setRadioChannel(radio)
end
end)

--- exports toggleRadioAnim
--- toggles whether the client should play radio anim or not, if the animation should be played or notvaliddance
exports('toggleRadioAnim', function()
disableRadioAnim = not disableRadioAnim
TriggerEvent('pma-voice:toggleRadioAnim', disableRadioAnim)
end)

-- exports disableRadioAnim
--- returns whether the client is undercover or not
exports('getRadioAnimState', function()
return toggleRadioAnim
end)

--- check if the player is dead
--- seperating this so if people use different methods they can customize
--- it to their need as this will likely never be changed
--- but you can integrate the below state bag to your death resources.
--- LocalPlayer.state:set('isDead', true or false, false)
function isDead()
if LocalPlayer.state.isDead then
return true
elseif IsPlayerDead(PlayerId()) then
return true
end
end

RegisterCommand('+radiotalk', function()
if GetConvarInt('voice_enableRadios', 1) ~= 1 then return end
if isDead() then return end

if not radioPressed and radioEnabled then
if radioChannel > 0 then
logger.info('[radio] Start broadcasting, update targets and notify server.')
playerTargets(radioData, MumbleIsPlayerTalking(PlayerId()) and callData or {})
TriggerServerEvent('pma-voice:setTalkingOnRadio', true)
radioPressed = true
playMicClicks(true)
if GetConvarInt('voice_enableRadioAnim', 0) == 1 and not (GetConvarInt('voice_disableVehicleRadioAnim', 0) == 1 and IsPedInAnyVehicle(PlayerPedId(), false)) then
if not disableRadioAnim then
RequestAnimDict('random@arrests')
while not HasAnimDictLoaded('random@arrests') do
Citizen.Wait(10)
end
TaskPlayAnim(PlayerPedId(), "random@arrests", "generic_radio_enter", 8.0, 2.0, -1, 50, 2.0, 0, 0, 0)
end
end
Citizen.CreateThread(function()
TriggerEvent("pma-voice:radioActive", true)
while radioPressed do
Wait(0)
SetControlNormal(0, 249, 1.0)
SetControlNormal(1, 249, 1.0)
SetControlNormal(2, 249, 1.0)
end
end)
end
end
end, false)

RegisterCommand('-radiotalk', function()
if radioChannel > 0 or radioEnabled and radioPressed then
radioPressed = false
MumbleClearVoiceTargetPlayers(voiceTarget)
playerTargets(MumbleIsPlayerTalking(PlayerId()) and callData or {})
TriggerEvent("pma-voice:radioActive", false)
playMicClicks(false)
if GetConvarInt('voice_enableRadioAnim', 0) == 1 then
StopAnimTask(PlayerPedId(), "random@arrests", "generic_radio_enter", -4.0)
end
TriggerServerEvent('pma-voice:setTalkingOnRadio', false)
end
end, false)
if gameVersion == 'fivem' then
RegisterKeyMapping('+radiotalk', 'Talk over Radio', 'keyboard', GetConvar('voice_defaultRadio', 'LMENU'))
end

--- event syncRadio
--- syncs the players radio, only happens if the radio was set server side.
---@param _radioChannel number the radio channel to set the player to.
function syncRadio(_radioChannel)
if GetConvarInt('voice_enableRadios', 1) ~= 1 then return end
logger.info('[radio] radio set serverside update to radio %s', radioChannel)
radioChannel = _radioChannel
end
RegisterNetEvent('pma-voice:clSetPlayerRadio', syncRadio)

View file

@ -0,0 +1,11 @@
local uiReady = promise.new()
function sendUIMessage(message)
Citizen.Await(uiReady)
SendNUIMessage(message)
end

RegisterNUICallback("uiReady", function(data, cb)
uiReady:resolve(true)

cb('ok')
end)

View file

@ -0,0 +1 @@
theme: jekyll-theme-midnight

View file

@ -0,0 +1,27 @@
## setTalkingMode | settingsCallback | radioACtive

## Description

These event is designed to allow third part applications (like a hud) use the current voice mode of the player, radio state, etc.

```lua
-- default voice mode is 2
local voiceMode = 2
local voiceModes = {}
local usingRadio = false
-- sets the current radio state boolean
AddEventHandler("pma-voice:radioActive", function(radioTalking) usingRadio = radioTalking end)
-- changes the current voice range index
AddEventHandler('pma-voice:setTalkingMode', function(newTalkingRange) voiceMode = newTalkingRange end)
-- returns registered voice modes from shared.lua's `Cfg.voiceModes`
TriggerEvent("pma-voice:settingsCallback", function(voiceSettings)
local voiceTable = voiceSettings.voiceModes

-- loop through all voice modes and add them to the table
-- the percentage is used for the voice mode slider if this was an actual UI
for i = 1, #voiceTable do
local distance = math.ceil(((i/#voiceTable) * 100))
voiceModes[i] = ("%s"):format(distance)
end
end)
```

View file

@ -0,0 +1,12 @@
## removePlayerFromCall

## Description

Removes the player from the call

## NOTE: This is just syntactic sugar for `setCallChannel(0)`

```lua
-- Removes the player from the call channel
exports['pma-voice']:removePlayerFromCall()
```

View file

@ -0,0 +1,12 @@
## removePlayerFromRadio

## Description

Removes the player from the radio

## NOTE: This is just syntactic sugar for `setRadioChannel(0)`

```lua
-- Removes the player from the radio channel
exports['pma-voice']:removePlayerFromRadio()
```

View file

@ -0,0 +1,25 @@
## setCallChannel | addPlayerToCall | SetCallChannel

## Description

Sets the local players call channel.

## Parameters

* **callChannel**: the call channel to join


```lua
-- Joins call channel 1
exports['pma-voice']:setCallChannel(1)

-- This will remove them from the call channel
exports['pma-voice']:setCallChannel(0)
```

addPlayerToCall is provided as a 'easier to read' version of setCallChannel.

```lua
-- Joins call channel 1
exports['pma-voice']:addPlayerToCall(1)
```

View file

@ -0,0 +1,14 @@
## setCallVolume

## Description

Sets the local players call channel volume

## Parameters

* **callVolume**: the call volume to set to between 0 - 100 percent

```lua
-- set the call volume to 50 percent
exports['pma-voice']:setCallVolume(50)
```

View file

@ -0,0 +1,26 @@
## setRadioChannel | addPlayerToRadio | SetCallChannel

## Description

Sets the local players radio channel.

## Parameters

* **radioChannel**: the radio channel to join

## NOTE: If the player fails the server side radio channel check they will be reset to no channel.

```lua
-- Joins radio channel 1
exports['pma-voice']:setRadioChannel(1)

-- This will remove the player from all radio channels
expots ['pma-voice']:setRadioChannel(0)
```

addPlayerToRadio is provided as a 'easier to read' alternative to setRadioChannel.

```lua
-- Joins radio channel 1
exports['pma-voice']:addPlayerToRadio(1)
```

View file

@ -0,0 +1,14 @@
## setRadioVolume

## Description

Sets the local players radio channel volume

## Parameters

* **radioVolume**: the radio volume to set to between 0 - 100 percent

```lua
-- sets the radio volume to 50 percent
exports['pma-voice']:setRadioVolume(50)
```

View file

@ -0,0 +1,17 @@
## setVoiceProperty | SetMumbleProperty | SetTokoProperty

## Description

Sets the voice property, currently the only use is to enable/disable radios and radio clicks.

## Parameters

* **property**: The property to set
* **value**: The value to set the property to

```lua
-- Enable the radio
exports['pma-voice']:setVoiceProperty('radioEnabled', true)
-- Disable radio clicks
exports['pma-voice']:setVoiceProperty('micClicks', false)
```

View file

@ -0,0 +1,3 @@
## Routing Buckets

pma-voice natively supports routing buckets.

View file

@ -0,0 +1,21 @@
## getPlayersInRadioChannel

## Description

Gets a list of all of the players in the specified radio channel.

## Parameters

* **radioChannel**: The channel to get all the members of

## Returns

Returns a table of all of the players in the specified radio channel

```lua
-- this will return all of the current players in radio channel 1
local players = exports['pma-voice']:getPlayersInRadioChannel(1)
for source, isTalking in pairs(players) do
print(('%s is in radio channel 1, isTalking: %s'):format(GetPlayerName(source), isTalking))
end
```

View file

@ -0,0 +1,22 @@
## addChannelCheck

## Description

Adds a channel check to radio channels.

## Parameters

* **channel**: The channel to add the check to.
* **function**: the function to call when the check is triggered, which should return a boolean of if the player is allowed to join the channel..


```lua
-- Example for addChannelCheck
-- this always has to return true/false
exports['pma-voice']:addChannelCheck(1, function(source)
if IsPlayerAceAllowed(source, 'radio.police') then
return true
end
return false
end)
```

View file

@ -0,0 +1,14 @@
## setPlayerCall

## Description

Sets the players call channel.

## Parameters

* **source**: The player to set the radio channel of
* **callChannel**: the radio channel to set the player to

```lua
exports['pma-voice']:setPlayerCall(source, 1)
```

View file

@ -0,0 +1,14 @@
## setPlayerRadio

## Description

Sets the players radio channel.

## Parameters

* **source**: The player to set the radio channel of
* **radioChannel**: the radio channel to set the player to

```lua
exports['pma-voice']:setPlayerRadio(source, 1)
```

View file

@ -0,0 +1,17 @@
## State Bag Getters/Setters

## Description

State bag getters are a little bit simpler, they just return the current value that is set in the state bag.

#### Note: If you're on the client and only using it on the current player, you can replace Player(source) with LocalPlayer

## Example for Proximity

```lua
local plyState = Player(source).state
local proximity = plyState.proximity
print(proximity.index) -- prints the index of the proximity as seen in Cfg.voiceModes
print(proximity.distance) -- prints the distance of the proximity
print(proximity.mode) -- prints the mode name of the proximity
```

View file

@ -0,0 +1,69 @@
game 'common'

fx_version 'cerulean'
author 'AvarianKnight'
description 'VOIP built using FiveM\'s built in mumble.'

dependencies {
'/onesync',
}

lua54 'yes'

shared_script 'shared.lua'

client_scripts {
'client/utils/*',
'client/init/proximity.lua',
'client/init/init.lua',
'client/init/main.lua',
'client/module/*.lua',
'client/*.lua',
}

server_scripts {
'server/**/*.lua',
'server/**/*.js'
}

files {
'ui/*.ogg',
'ui/css/*.css',
'ui/js/*.js',
'ui/index.html',
}

ui_page 'ui/index.html'

provides {
'mumble-voip',
'tokovoip',
'toko-voip',
'tokovoip_script'
}

convar_category 'PMA-Voice' {
"PMA-Voice Configuration Options",
{
{ "Use native audio", "$voice_useNativeAudio", "CV_BOOL", "false" },
{ "Use 2D audio", "$voice_use2dAudio", "CV_BOOL", "false" },
{ "Use sending range only", "$voice_useSendingRangeOnly", "CV_BOOL", "false" },
{ "Enable UI", "$voice_enableUi", "CV_INT", "1" },
{ "Enable F11 proximity key", "$voice_enableProximityCycle", "CV_INT", "1" },
{ "Proximity cycle key", "$voice_defaultCycle", "CV_STRING", "F11" },
{ "Voice radio volume", "$voice_defaultRadioVolume", "CV_INT", "30" },
{ "Voice phone volume", "$voice_defaultPhoneVolume", "CV_INT", "60" },
{ "Enable radios", "$voice_enableRadios", "CV_INT", "1" },
{ "Enable phones", "$voice_enablePhones", "CV_INT", "1" },
{ "Enable submix", "$voice_enableSubmix", "CV_INT", "1" },
{ "Enable radio animation", "$voice_enableRadioAnim", "CV_INT", "0" },
{ "Radio key", "$voice_defaultRadio", "CV_STRING", "LALT" },
{ "UI refresh rate", "$voice_uiRefreshRate", "CV_INT", "200" },
{ "Allow players to set audio intent", "$voice_allowSetIntent", "CV_INT", "1" },
{ "External mumble server address", "$voice_externalAddress", "CV_STRING", "" },
{ "External mumble server port", "$voice_externalPort", "CV_INT", "0" },
{ "Voice debug mode", "$voice_debugMode", "CV_INT", "0" },
{ "Disable players being allowed to join", "$voice_externalDisallowJoin", "CV_INT", "0" },
{ "Hide server endpoints in logs", "$voice_hideEndpoints", "CV_INT", "1" },
}
}

View file

@ -0,0 +1,139 @@
voiceData = {}
radioData = {}
callData = {}

function defaultTable(source)
handleStateBagInitilization(source)
return {
radio = 0,
call = 0,
lastRadio = 0,
lastCall = 0
}
end

function handleStateBagInitilization(source)
local plyState = Player(source).state
if not plyState.pmaVoiceInit then
plyState:set('radio', GetConvarInt('voice_defaultRadioVolume', 30), true)
plyState:set('phone', GetConvarInt('voice_defaultPhoneVolume', 60), true)
plyState:set('proximity', {}, true)
plyState:set('callChannel', 0, true)
plyState:set('radioChannel', 0, true)
plyState:set('voiceIntent', 'speech', true)
-- We want to save voice inits because we'll automatically reinitalize calls and channels
plyState:set('pmaVoiceInit', true, false)
end
end

Citizen.CreateThread(function()

local plyTbl = GetPlayers()
for i = 1, #plyTbl do
local ply = tonumber(plyTbl[i])
voiceData[ply] = defaultTable(plyTbl[i])
end

Wait(5000)

local nativeAudio = GetConvar('voice_useNativeAudio', 'false')
local _3dAudio = GetConvar('voice_use3dAudio', 'false')
local _2dAudio = GetConvar('voice_use2dAudio', 'false')
local sendingRangeOnly = GetConvar('voice_useSendingRangeOnly', 'false')
local gameVersion = GetConvar('gamename', 'fivem')

-- handle no convars being set (default drag n' drop)
if
nativeAudio == 'false'
and _3dAudio == 'false'
and _2dAudio == 'false'
then
if gameVersion == 'fivem' then
SetConvarReplicated('voice_useNativeAudio', 'true')
if sendingRangeOnly == 'false' then
SetConvarReplicated('voice_useSendingRangeOnly', 'true')
end
logger.info('No convars detected for voice mode, defaulting to \'setr voice_useNativeAudio true\' and \'setr voice_useSendingRangeOnly true\'')
else
SetConvarReplicated('voice_use3dAudio', 'true')
if sendingRangeOnly == 'false' then
SetConvarReplicated('voice_useSendingRangeOnly', 'true')
end
logger.info('No convars detected for voice mode, defaulting to \'setr voice_use3dAudio true\' and \'setr voice_useSendingRangeOnly true\'')
end
elseif sendingRangeOnly == 'false' then
logger.warn('It\'s recommended to have \'voice_useSendingRangeOnly\' set to true you can do that with \'setr voice_useSendingRangeOnly true\', this prevents players who directly join the mumble server from broadcasting to players.')
end

if GetConvar('gamename', 'fivem') == 'rdr3' then
if nativeAudio == 'true' then
logger.warn("RedM doesn't currently support native audio, automatically switching to 3d audio. This also means that submixes will not work.")
SetConvarReplicated('voice_useNativeAudio', 'false')
SetConvarReplicated('voice_use3dAudio', 'true')
end
end

local radioVolume = GetConvarInt("voice_defaultRadioVolume", 30)
local phoneVolume = GetConvarInt("voice_defaultPhoneVolume", 60)

-- When casted to an integer these get set to 0 or 1, so warn on these values that they don't work
if
radioVolume == 0 or radioVolume == 1 or
phoneVolume == 0 or phoneVolume == 1
then
SetConvarReplicated("voice_defaultRadioVolume", 30)
SetConvarReplicated("voice_defaultPhoneVolume", 60)
for i = 1, 5 do
Wait(5000)
logger.warn("`voice_defaultRadioVolume` or `voice_defaultPhoneVolume` have their value set as a float, this is going to automatically be fixed but please update your convars.")
end
end
end)

AddEventHandler('playerJoining', function()
if not voiceData[source] then
voiceData[source] = defaultTable(source)
end
end)

AddEventHandler("playerDropped", function()
local source = source
if voiceData[source] then
local plyData = voiceData[source]

if plyData.radio ~= 0 then
removePlayerFromRadio(source, plyData.radio)
end

if plyData.call ~= 0 then
removePlayerFromCall(source, plyData.call)
end

voiceData[source] = nil
end
end)

if GetConvarInt('voice_externalDisallowJoin', 0) == 1 then
AddEventHandler('playerConnecting', function(_, _, deferral)
deferral.defer()
Wait(0)
deferral.done('This server is not accepting connections.')
end)
end

-- only meant for internal use so no documentation
function isValidPlayer(source)
return voiceData[source]
end
exports('isValidPlayer', isValidPlayer)

function getPlayersInRadioChannel(channel)
local returnChannel = radioData[channel]
if returnChannel then
return returnChannel
end
-- channel doesnt exist
return {}
end
exports('getPlayersInRadioChannel', getPlayersInRadioChannel)
exports('GetPlayersInRadioChannel', getPlayersInRadioChannel)

View file

@ -0,0 +1,94 @@
--- removes a player from the call for everyone in the call.
---@param source number the player to remove from the call
---@param callChannel number the call channel to remove them from
function removePlayerFromCall(source, callChannel)
logger.verbose('[phone] Removed %s from call %s', source, callChannel)

callData[callChannel] = callData[callChannel] or {}
for player, _ in pairs(callData[callChannel]) do
TriggerClientEvent('pma-voice:removePlayerFromCall', player, source)
end
callData[callChannel][source] = nil
voiceData[source] = voiceData[source] or defaultTable(source)
voiceData[source].call = 0
end

--- adds a player to a call
---@param source number the player to add to the call
---@param callChannel number the call channel to add them to
function addPlayerToCall(source, callChannel)
logger.verbose('[phone] Added %s to call %s', source, callChannel)
-- check if the channel exists, if it does set the varaible to it
-- if not create it (basically if not callData make callData)
callData[callChannel] = callData[callChannel] or {}
for player, _ in pairs(callData[callChannel]) do
-- don't need to send to the source because they're about to get sync'd!
if player ~= source then
TriggerClientEvent('pma-voice:addPlayerToCall', player, source)
end
end
callData[callChannel][source] = false
voiceData[source] = voiceData[source] or defaultTable(source)
voiceData[source].call = callChannel
TriggerClientEvent('pma-voice:syncCallData', source, callData[callChannel])
end

--- set the players call channel
---@param source number the player to set the call off
---@param _callChannel number the channel to set the player to (or 0 to remove them from any call channel)
function setPlayerCall(source, _callChannel)
if GetConvarInt('voice_enablePhones', 1) ~= 1 then return end
voiceData[source] = voiceData[source] or defaultTable(source)
local isResource = GetInvokingResource()
local plyVoice = voiceData[source]
local callChannel = tonumber(_callChannel)
if not callChannel then
-- only full error if its sent from another server-side resource
if isResource then
error(("'callChannel' expected 'number', got: %s"):format(type(_callChannel)))
else
return logger.warn("%s sent a invalid call, 'callChannel' expected 'number', got: %s", source,type(_callChannel))
end
end
if isResource then
-- got set in a export, need to update the client to tell them that their call
-- changed
TriggerClientEvent('pma-voice:clSetPlayerCall', source, callChannel)
end

Player(source).state.callChannel = callChannel

if callChannel ~= 0 and plyVoice.call == 0 then
addPlayerToCall(source, callChannel)
elseif callChannel == 0 then
removePlayerFromCall(source, plyVoice.call)
elseif plyVoice.call > 0 then
removePlayerFromCall(source, plyVoice.call)
addPlayerToCall(source, callChannel)
end
end
exports('setPlayerCall', setPlayerCall)

RegisterNetEvent('pma-voice:setPlayerCall', function(callChannel)
setPlayerCall(source, callChannel)
end)

function setTalkingOnCall(talking)
if GetConvarInt('voice_enablePhones', 1) ~= 1 then return end
local source = source
voiceData[source] = voiceData[source] or defaultTable(source)
local plyVoice = voiceData[source]
local callTbl = callData[plyVoice.call]
if callTbl then
logger.verbose('[phone] %s %s talking in call %s', source, talking and 'started' or 'stopped', plyVoice.call)
for player, _ in pairs(callTbl) do
if player ~= source then
logger.verbose('[call] Sending event to %s to tell them that %s is talking', player, source)
TriggerClientEvent('pma-voice:setTalkingOnCall', player, source, talking)
end
end
else
logger.verbose('[phone] %s tried to talk in call %s, but it doesnt exist.', source, plyVoice.call)
end
end
RegisterNetEvent('pma-voice:setTalkingOnCall', setTalkingOnCall)

View file

@ -0,0 +1,165 @@
local radioChecks = {}

--- checks if the player can join the channel specified
--- @param source number the source of the player
--- @param radioChannel number the channel they're trying to join
--- @return boolean if the user can join the channel
function canJoinChannel(source, radioChannel)
if radioChecks[radioChannel] then
return radioChecks[radioChannel](source)
end
return true
end

--- adds a check to the channel, function is expected to return a boolean of true or false
---@param channel number the channel to add a check to
---@param cb function the function to execute the check on
function addChannelCheck(channel, cb)
local channelType = type(channel)
local cbType = type(cb)
if channelType ~= "number" then
error(("'channel' expected 'number' got '%s'"):format(channelType))
end
if cbType ~= 'table' or not cb.__cfx_functionReference then
error(("'cb' expected 'function' got '%s'"):format(cbType))
end
radioChecks[channel] = cb
logger.info("%s added a check to channel %s", GetInvokingResource(), channel)
end
exports('addChannelCheck', addChannelCheck)

local function radioNameGetter_orig(source)
return GetPlayerName(source)
end
local radioNameGetter = radioNameGetter_orig

--- adds a check to the channel, function is expected to return a boolean of true or false
---@param cb function the function to execute the check on
function overrideRadioNameGetter(channel, cb)
local cbType = type(cb)
if cbType == 'table' and not cb.__cfx_functionReference then
error(("'cb' expected 'function' got '%s'"):format(cbType))
end
radioNameGetter = cb
logger.info("%s added a check to channel %s", GetInvokingResource(), channel)
end
exports('overrideRadioNameGetter', overrideRadioNameGetter)

--- adds a player to the specified radion channel
---@param source number the player to add to the channel
---@param radioChannel number the channel to set them to
function addPlayerToRadio(source, radioChannel)
if not canJoinChannel(source, radioChannel) then
-- remove the player from the radio client side
return TriggerClientEvent('pma-voice:removePlayerFromRadio', source, source)
end
logger.verbose('[radio] Added %s to radio %s', source, radioChannel)

-- check if the channel exists, if it does set the varaible to it
-- if not create it (basically if not radiodata make radiodata)
radioData[radioChannel] = radioData[radioChannel] or {}
local plyName = radioNameGetter(source)
for player, _ in pairs(radioData[radioChannel]) do
TriggerClientEvent('pma-voice:addPlayerToRadio', player, source, plyName)
end
voiceData[source] = voiceData[source] or defaultTable(source)
voiceData[source].radio = radioChannel
radioData[radioChannel][source] = false
TriggerClientEvent('pma-voice:syncRadioData', source, radioData[radioChannel], GetConvarInt("voice_syncPlayerNames", 0) == 1 and plyName)
end

--- removes a player from the specified channel
---@param source number the player to remove
---@param radioChannel number the current channel to remove them from
function removePlayerFromRadio(source, radioChannel)
logger.verbose('[radio] Removed %s from radio %s', source, radioChannel)
radioData[radioChannel] = radioData[radioChannel] or {}
for player, _ in pairs(radioData[radioChannel]) do
TriggerClientEvent('pma-voice:removePlayerFromRadio', player, source)
end
radioData[radioChannel][source] = nil
voiceData[source] = voiceData[source] or defaultTable(source)
voiceData[source].radio = 0
end

-- TODO: Implement this in a way that allows players to be on multiple channels
--- sets the players current radio channel
---@param source number the player to set the channel of
---@param _radioChannel number the radio channel to set them to (or 0 to remove them from radios)
function setPlayerRadio(source, _radioChannel)
if GetConvarInt('voice_enableRadios', 1) ~= 1 then return end
voiceData[source] = voiceData[source] or defaultTable(source)
local isResource = GetInvokingResource()
local plyVoice = voiceData[source]
local radioChannel = tonumber(_radioChannel)
if not radioChannel then
-- only full error if its sent from another server-side resource
if isResource then
error(("'radioChannel' expected 'number', got: %s"):format(type(_radioChannel)))
else
return logger.warn("%s sent a invalid radio, 'radioChannel' expected 'number', got: %s", source,type(_radioChannel))
end
end
if isResource then
-- got set in a export, need to update the client to tell them that their radio
-- changed
TriggerClientEvent('pma-voice:clSetPlayerRadio', source, radioChannel)
end
Player(source).state.radioChannel = radioChannel
if radioChannel ~= 0 and plyVoice.radio == 0 then
addPlayerToRadio(source, radioChannel)
elseif radioChannel == 0 then
removePlayerFromRadio(source, plyVoice.radio)
elseif plyVoice.radio > 0 then
removePlayerFromRadio(source, plyVoice.radio)
addPlayerToRadio(source, radioChannel)
end
end
exports('setPlayerRadio', setPlayerRadio)

RegisterNetEvent('pma-voice:setPlayerRadio', function(radioChannel)
setPlayerRadio(source, radioChannel)
end)

--- syncs the player talking across all radio members
---@param talking boolean sets if the palyer is talking.
function setTalkingOnRadio(talking)
if GetConvarInt('voice_enableRadios', 1) ~= 1 then return end
voiceData[source] = voiceData[source] or defaultTable(source)
local plyVoice = voiceData[source]
local radioTbl = radioData[plyVoice.radio]
if radioTbl then
radioTbl[source] = talking
logger.verbose('[radio] Set %s to talking: %s on radio %s',source, talking, plyVoice.radio)
for player, _ in pairs(radioTbl) do
if player ~= source then
TriggerClientEvent('pma-voice:setTalkingOnRadio', player, source, talking)
logger.verbose('[radio] Sync %s to let them know %s is %s',player, source, talking and 'talking' or 'not talking')
end
end
end
end
RegisterNetEvent('pma-voice:setTalkingOnRadio', setTalkingOnRadio)

AddEventHandler("onResourceStop", function(resource)
for channel, cfxFunctionRef in pairs(radioChecks) do
local functionRef = cfxFunctionRef.__cfx_functionReference
local functionResource = string.match(functionRef, resource)
if functionResource then
radioChecks[channel] = nil
logger.warn('Channel %s had its radio check removed because the resource that gave the checks stopped', channel)
end
end

if type(radioNameGetter) == "table" then
local radioRef = radioNameGetter.__cfx_functionReference
if radioRef then
local isResource = string.match(functionRef, resource)
if isResource then
radioNameGetter = radioNameGetter_orig
logger.warn('Radio name getter is resetting to default because the resource that gave the cb got turned off')
end
end
end

end)

View file

@ -0,0 +1,26 @@
let mutedPlayers = {}
// this is implemented in JS due to Lua's lack of a ClearTimeout
// muteply instead of mute because mute conflicts with rp-radio
RegisterCommand('muteply', (source, args) => {
const mutePly = parseInt(args[0])
const duration = parseInt(args[1]) || 900
if (mutePly && exports['pma-voice'].isValidPlayer(mutePly)) {
const isMuted = !MumbleIsPlayerMuted(mutePly);
Player(mutePly).state.muted = isMuted;
MumbleSetPlayerMuted(mutePly, isMuted);
emit('pma-voice:playerMuted', mutePly, source, isMuted, duration);
// since this is a toggle, if theres a mutedPlayers entry it can be assumed
// that they're currently muted, so we'll clear the timeout and unmute
if (mutedPlayers[mutePly]) {
clearTimeout(mutedPlayers[mutePly]);
MumbleSetPlayerMuted(mutePly, isMuted)
Player(mutePly).state.muted = isMuted;
return;
}
mutedPlayers[mutePly] = setTimeout(() => {
MumbleSetPlayerMuted(mutePly, !isMuted)
Player(mutePly).state.muted = !isMuted;
delete mutedPlayers[mutePly]
}, duration * 1000)
}
}, true)

View file

@ -0,0 +1,93 @@
Cfg = {}

voiceTarget = 1

gameVersion = GetGameName()

-- these are just here to satisfy linting
if not IsDuplicityVersion() then
LocalPlayer = LocalPlayer
playerServerId = GetPlayerServerId(PlayerId())
end
Player = Player
Entity = Entity

if GetConvar('voice_useNativeAudio', 'false') == 'true' then
-- native audio distance seems to be larger then regular gta units
Cfg.voiceModes = {
{1.5, "Whisper"}, -- Whisper speech distance in gta distance units
{3.0, "Normal"}, -- Normal speech distance in gta distance units
{6.0, "Shouting"} -- Shout speech distance in gta distance units
}
else
Cfg.voiceModes = {
{3.0, "Whisper"}, -- Whisper speech distance in gta distance units
{7.0, "Normal"}, -- Normal speech distance in gta distance units
{15.0, "Shouting"} -- Shout speech distance in gta distance units
}
end

logger = {
log = function(message, ...)
print((message):format(...))
end,
info = function(message, ...)
if GetConvarInt('voice_debugMode', 0) >= 1 then
print(('[info] ' .. message):format(...))
end
end,
warn = function(message, ...)
print(('[^1WARNING^7] ' .. message):format(...))
end,
error = function(message, ...)
error((message):format(...))
end,
verbose = function(message, ...)
if GetConvarInt('voice_debugMode', 0) >= 4 then
print(('[verbose] ' .. message):format(...))
end
end,
}


function tPrint(tbl, indent)
indent = indent or 0
for k, v in pairs(tbl) do
local tblType = type(v)
local formatting = string.rep(" ", indent) .. k .. ": "

if tblType == "table" then
print(formatting)
tPrint(v, indent + 1)
elseif tblType == 'boolean' then
print(formatting .. tostring(v))
elseif tblType == "function" then
print(formatting .. tostring(v))
else
print(formatting .. v)
end
end
end

local function types(args)
local argType = type(args[1])
for i = 2, #args do
local arg = args[i]
if argType == arg then
return true, argType
end
end
return false, argType
end

function type_check(...)
local vars = {...}
for i = 1, #vars do
local var = vars[i]
local matchesType, varType = types(var)
if not matchesType then
table.remove(var, 1)
error(("Invalid type sent to argument #%s, expected %s, got %s"):format(i, table.concat(var, "|"), varType))
end
end
end

View file

@ -0,0 +1 @@
.voiceInfo{font-family:Avenir,Helvetica,Arial,sans-serif;position:fixed;text-align:right;bottom:5px;padding:0;right:5px;font-size:12px;font-weight:700;color:#949697;text-shadow:1.25px 0 0 #000,0 -1.25px 0 #000,0 1.25px 0 #000,-1.25px 0 0 #000}.talking{color:hsla(0,0%,100%,.822)}p{margin:0}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,3 @@
> 1%
last 2 versions
not dead

View file

@ -0,0 +1,23 @@
.DS_Store
node_modules
/dist


# local env files
.env.local
.env.*.local

# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View file

@ -0,0 +1,24 @@
# voice-ui

## Project setup
```
yarn install
```

### Compiles and hot-reloads for development
```
yarn serve
```

### Compiles and minifies for production
```
yarn build
```

### Lints and fixes files
```
yarn lint
```

### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

View file

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

View file

@ -0,0 +1,18 @@
{
"name": "voice-ui",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"core-js": "^3.6.5",
"vue": "^3.0.0"
},
"devDependencies": {
"@vue/cli-service": "~4.5.0",
"@vue/compiler-sfc": "^3.0.0"
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

View file

@ -0,0 +1,112 @@
<template>
<body>
<audio id="audio_on" src="mic_click_on.ogg"></audio>
<audio id="audio_off" src="mic_click_off.ogg"></audio>
<div v-if="voice.uiEnabled" class="voiceInfo">
<p v-if="voice.callInfo !== 0" :class="{ talking: voice.talking }">
[Call]
</p>
<p v-if="voice.radioEnabled && voice.radioChannel !== 0" :class="{ talking: voice.usingRadio }">
{{ voice.radioChannel }} Mhz [Radio]
</p>
<p v-if="voice.voiceModes.length" :class="{ talking: voice.talking }">
{{ voice.voiceModes[voice.voiceMode][1] }} [Range]
</p>
</div>
</body>
</template>

<script>
import { reactive } from "vue";
export default {
name: "App",
setup() {
const voice = reactive({
uiEnabled: true,
voiceModes: [],
voiceMode: 0,
radioChannel: 0,
radioEnabled: true,
usingRadio: false,
callInfo: 0,
talking: false,
});

// stops from toggling voice at the end of talking
window.addEventListener("message", function(event) {
const data = event.data;

if (data.uiEnabled !== undefined) {
voice.uiEnabled = data.uiEnabled
}

if (data.voiceModes !== undefined) {
voice.voiceModes = JSON.parse(data.voiceModes);
// Push our own custom type for modes that have their range changed
let voiceModes = [...voice.voiceModes]
voiceModes.push([0.0, "Custom"])
voice.voiceModes = voiceModes
}

if (data.voiceMode !== undefined) {
voice.voiceMode = data.voiceMode;
}

if (data.radioChannel !== undefined) {
voice.radioChannel = data.radioChannel;
}

if (data.radioEnabled !== undefined) {
voice.radioEnabled = data.radioEnabled;
}

if (data.callInfo !== undefined) {
voice.callInfo = data.callInfo;
}

if (data.usingRadio !== undefined && data.usingRadio !== voice.usingRadio) {
voice.usingRadio = data.usingRadio;
}
if ((data.talking !== undefined) && !voice.usingRadio) {
voice.talking = data.talking;
}

if (data.sound && voice.radioEnabled && voice.radioChannel !== 0) {
let click = document.getElementById(data.sound);
// discard these errors as its usually just a 'uncaught promise' from two clicks happening too fast.
click.load();
click.volume = data.volume;
click.play().catch((e) => {});
}
});

fetch(`https://${GetParentResourceName()}/uiReady`, { method: 'POST' });

return { voice };
}
};
</script>

<style>
.voiceInfo {
font-family: Avenir, Helvetica, Arial, sans-serif;
position: fixed;
text-align: right;
bottom: 5px;
padding: 0;
right: 5px;
font-size: 12px;
font-weight: bold;
color: rgb(148, 150, 151);
/* https://stackoverflow.com/questions/4772906/css-is-it-possible-to-add-a-black-outline-around-each-character-in-text */
text-shadow: 1.25px 0 0 #000, 0 -1.25px 0 #000, 0 1.25px 0 #000,
-1.25px 0 0 #000;
}
.talking {
color: rgba(255, 255, 255, 0.822);
}
p {
margin: 0;
}
</style>

View file

@ -0,0 +1,4 @@
import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

View file

@ -0,0 +1,7 @@
module.exports = {
publicPath: './',
productionSourceMap: true,
filenameHashing: false,
outputDir: "../ui",

}