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,32 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Did you read the README?**: There's a lot of information in the README, if you ask a question that is answered in the README, you will be told to "Read the README"
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See an error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Server Info:**
- Server Version: [e.g. 3456]
- Client Version: [e.g. production|canary]
- Client Build: [e.g. b1604|b2060|b2189|b2372]
- Game: [e.g. FiveM|RedM]
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,18 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 30
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- pinned
- security
- todo
# Label to use when marking an issue as stale
staleLabel: wontfix
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false

View File

@@ -0,0 +1,2 @@
.idea
.vscode

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,201 @@
## PLEASE NOTE: Currently master branch has some breaking changes
If you previously used `voice_defaultPhoneVolume` you will instead need to use `voice_defaultCallVolume`
If you previously used `voice_enablePhones` you will instead need to use `voice_enableCalls`
If you were previously using the state bag getter `Player(source).state.phone` you will instead need to use `Player(source).state.call`
# 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)
# 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`, `MumbleSetTalkerProximity`, `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]`
| ConVar | Default | Description | Parameter(s) |
|----------------------------|---------|---------------------------------------------------------------|--------------|
| voice_useNativeAudio | false | 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_defaultCallVolume | 60 | The default volume to set the call 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_onClickVolume | 10 | The default volume to set the radio turn on mic click's 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_offClickVolume | 3 | The default volume to set the radio turn off mic click's 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 |
### Call & Radio
| ConVar | Default | Description | Parameter(s) |
|-------------------------|---------|--------------------------------------------------------------------|--------------|
| voice_enableRadios | 1 | Enables the radio sub-modules | int |
| voice_enableCalls | 1 | Enables the call sub-modules | int |
| voice_enableSubmix | 1 | Enables the submix which adds a radio/call style submix to their voice **NOTE: Submixs require native audio** | int |
| voice_enableRadioAnim | 1 | 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 |
| [setMicClickOnVolume](docs/client-setters/setMicClickOnVolume.md) | Set mic click on volume for player | int |
| [setMicClickOffVolume](docs/client-setters/setCallVolume.md) | Set mic click off 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.
| 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 |
| [disableRadio](docs/state-getters/stateBagGetters.md) | Returns if the players radio is currently disabled, or 0 if its not. This is expected to be use as a bitwise, do *not* use a bool | 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 |
| [removeChannelCheck](docs/server-setters/removeChannelCheck.md) | Removes a channel check for the players radio channel | int |
##### 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 |
| [disableRadio](docs/state-getters/stateBagGetters.md) | Returns if the players radio is currently disabled, or 0 if its not. This is expected to be use as a bitwise, do *not* use a bool | int |
```ts
enum DisabledRadioStates {
Enabled = 0,
IsDead = 1,
IsCuffed = 2,
IsPdCuffed = 4,
IsUnderWater = 8,
DoesntHaveItem = 16,
PlayerDisabledRadio = 32,
}
```
###### 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,5 @@
## TODO
- [ ] 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 Svelte.
- [ ] Multiple radio channels.

View File

@@ -0,0 +1,107 @@
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)
TriggerEvent('chat:addSuggestion', '/setvoiceintent', 'Sets the players voice intent', {
{
name = "intent",
help = "speech is default and enables noise suppression & high pass filter, music disables both of these."
},
})
-- TODO: Better implementation of this?
RegisterCommand('vol', function(_, args)
if not args[1] then return end
setVolume(tonumber(args[1]))
end)
TriggerEvent('chat:addSuggestion', '/vol', 'Sets the radio/phone volume', {
{ name = "volume", help = "A range between 1-100 on how loud you want them to be" },
})
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
-- hacky workaround to the fact that you can't bind secondary key mappings to PTT
if gameVersion == 'fivem' then
local isSecondaryPttPressed = false
RegisterCommand("+secondary_ptt", function()
isSecondaryPttPressed = true
CreateThread(function()
while isSecondaryPttPressed do
SetControlNormal(0, 249, 1.0)
SetControlNormal(1, 249, 1.0)
SetControlNormal(2, 249, 1.0)
Wait(0)
end
end)
end)
RegisterCommand("-secondary_ptt", function()
isSecondaryPttPressed = false
end)
RegisterKeyMapping('+secondary_ptt', 'A keybind that lets you have a secondary PTT', 'PAD_ANALOGBUTTOn', GetConvar('voice_defaultSecondary', 'LUP_INDEX'))
end

View File

@@ -0,0 +1,49 @@
isInitialized = false
function handleInitialState()
local voiceModeData = Cfg.voiceModes[mode]
MumbleSetTalkerProximity(voiceModeData[1] + 0.0)
MumbleClearVoiceTarget(voiceTarget)
MumbleSetVoiceTarget(voiceTarget)
MumbleSetVoiceChannel(LocalPlayer.state.assignedChannel)
while MumbleGetVoiceChannelFromServerId(playerServerId) ~= LocalPlayer.state.assignedChannel do
Wait(100)
MumbleSetVoiceChannel(LocalPlayer.state.assignedChannel)
end
isInitialized = true
MumbleAddVoiceTargetChannel(voiceTarget, LocalPlayer.state.assignedChannel)
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)
isInitialized = false
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,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)

View File

@@ -0,0 +1,58 @@
local callChannel = 0
RegisterNetEvent('pma-voice:syncCallData', function(callTable, channel)
callData = callTable
handleRadioAndCallInit()
end)
RegisterNetEvent('pma-voice:addPlayerToCall', function(plySource)
toggleVoice(plySource, true, 'call')
callData[plySource] = true
end)
RegisterNetEvent('pma-voice:removePlayerFromCall', function(plySource)
if plySource == playerServerId then
for tgt, _ in pairs(callData) do
if tgt ~= playerServerId then
toggleVoice(tgt, false, 'call')
end
end
callData = {}
MumbleClearVoiceTargetPlayers(voiceTarget)
addVoiceTargets((radioPressed and isRadioEnabled()) and radioData or {}, callData)
else
callData[plySource] = nil
toggleVoice(plySource, radioData[plySource], 'call')
if MumbleIsPlayerTalking(PlayerId()) then
MumbleClearVoiceTargetPlayers(voiceTarget)
addVoiceTargets((radioPressed and isRadioEnabled()) and radioData or {}, callData)
end
end
end)
function setCallChannel(channel)
if GetConvarInt('voice_enableCalls', 1) ~= 1 then return end
TriggerServerEvent('pma-voice:setPlayerCall', channel)
callChannel = channel
sendUIMessage({
callInfo = channel
})
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_enableCalls', 1) ~= 1 then return end
callChannel = _callChannel
end)

View File

@@ -0,0 +1,313 @@
local radioChannel = 0
local radioNames = {}
local disableRadioAnim = false
local radioAnim = {
dict = "random@arrests",
anim = "generic_radio_enter",
}
---@return boolean isEnabled if radioEnabled is true and LocalPlayer.state.disableRadio is 0 (no bits set)
function isRadioEnabled()
return radioEnabled and LocalPlayer.state.disableRadio == 0
end
--- 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
local isEnabled = isRadioEnabled()
if isEnabled then
handleRadioAndCallInit()
end
sendUIMessage({
radioChannel = radioChannel,
radioEnabled = isEnabled
})
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)
radioData[plySource] = enabled
if not isRadioEnabled() then return logger.info("[radio] Ignoring setTalkingOnRadio. radioEnabled: %s disableRadio: %s", radioEnabled, LocalPlayer.state.disableRadio) end
-- If we're on a call we don't want to toggle their voice disabled this will break calls.
local enabled = enabled or callData[plySource]
toggleVoice(plySource, enabled, 'radio')
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
logger.info('[radio] %s joined radio %s %s', plySource, radioChannel,
radioPressed and " while we were talking, adding them to targets" or "")
if radioPressed then
addVoiceTargets(radioData, callData)
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 = {}
addVoiceTargets(callData)
else
toggleVoice(plySource, false, 'radio')
if radioPressed then
logger.info('[radio] %s left radio %s while we were talking, updating targets.', plySource, radioChannel)
addVoiceTargets(radioData, callData)
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)
RegisterNetEvent('pma-voice:radioChangeRejected', function()
logger.info("The server rejected your radio change.")
radioChannel = 0
end)
--- 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
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("setDisableRadioAnim", function(shouldDisable)
disableRadioAnim = shouldDisable
end)
-- exports disableRadioAnim
--- returns whether the client is undercover or not
exports('getRadioAnimState', function()
return disableRadioAnim
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
return false
end
function isRadioAnimEnabled()
if
GetConvarInt('voice_enableRadioAnim', 1) == 1
and not (GetConvarInt('voice_disableVehicleRadioAnim', 0) == 1
and IsPedInAnyVehicle(PlayerPedId(), false))
and not disableRadioAnim then
return true
end
return false
end
RegisterCommand('+radiotalk', function()
if GetConvarInt('voice_enableRadios', 1) ~= 1 then return end
if isDead() then return end
if not isRadioEnabled() then return end
if not radioPressed then
if radioChannel > 0 then
logger.info('[radio] Start broadcasting, update targets and notify server.')
addVoiceTargets(radioData, callData)
TriggerServerEvent('pma-voice:setTalkingOnRadio', true)
radioPressed = true
local shouldPlayAnimation = isRadioAnimEnabled()
playMicClicks(true)
-- localize here so in the off case someone changes this while its in use we
-- still remove our dictionary down below here
local dict = radioAnim.dict
local anim = radioAnim.anim
if shouldPlayAnimation then
RequestAnimDict(dict)
end
CreateThread(function()
TriggerEvent("pma-voice:radioActive", true)
LocalPlayer.state:set("radioActive", true, true);
local checkFailed = false
while radioPressed do
local ped = PlayerPedId()
if radioChannel < 0 or isDead() or not isRadioEnabled() or (GetConvarBool("voice_disableRadioWhenShooting", false) and IsPedShooting(ped)) then
checkFailed = true
break
end
if shouldPlayAnimation and HasAnimDictLoaded(dict) then
if not IsEntityPlayingAnim(ped, dict, anim, 3) then
TaskPlayAnim(ped, dict, anim, 8.0, 2.0, -1, 50, 2.0, false,
false,
false)
end
end
SetControlNormal(0, 249, 1.0)
SetControlNormal(1, 249, 1.0)
SetControlNormal(2, 249, 1.0)
Wait(0)
end
if checkFailed then
logger.info("Canceling radio talking as the checks have failed.")
ExecuteCommand("-radiotalk")
end
if shouldPlayAnimation then
RemoveAnimDict(dict)
end
end)
else
logger.info("Player tried to talk but was not on a radio channel")
end
end
end, false)
RegisterCommand('-radiotalk', function()
if radioChannel > 0 and radioPressed then
radioPressed = false
MumbleClearVoiceTargetPlayers(voiceTarget)
addVoiceTargets(callData)
TriggerEvent("pma-voice:radioActive", false)
LocalPlayer.state:set("radioActive", false, true);
playMicClicks(false)
if GetConvarInt('voice_enableRadioAnim', 1) == 1 then
StopAnimTask(PlayerPedId(), radioAnim.dict, radioAnim.anim, -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
local function setRadioTalkAnim(dict, anim)
type_check({dict, "string"}, {anim, "string"})
if not DoesAnimDictExist(dict) then
return error(("Dict: %s did not exist"):format(dict))
end
radioAnim.dict = dict
radioAnim.anim = anim
end
exports('setRadioTalkAnim', setRadioTalkAnim)
--- 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)
--- handles "radioEnabled" changing
---@param wasRadioEnabled boolean whether radio is enabled or not
function handleRadioEnabledChanged(wasRadioEnabled)
if wasRadioEnabled then
syncRadioData(radioData, "")
else
removePlayerFromRadio(playerServerId)
end
end
--- adds the bit to the disableRadio bits
---@param bit number the bit to add
local function addRadioDisableBit(bit)
local curVal = LocalPlayer.state.disableRadio or 0
curVal = curVal | bit
LocalPlayer.state:set("disableRadio", curVal, true)
end
exports("addRadioDisableBit", addRadioDisableBit)
--- removes the bit from disableRadio
---@param bit number the bit to remove
local function removeRadioDisableBit(bit)
local curVal = LocalPlayer.state.disableRadio or 0
curVal = curVal & (~bit)
LocalPlayer.state:set("disableRadio", curVal, true)
end
exports("removeRadioDisableBit", removeRadioDisableBit)

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,14 @@
## setMicClickOffVolume
## Description
Sets the local players radio turn off mic click volume
## Parameters
* **volume**: the volume to change to between 0 - 100 percent
```lua
-- sets the radio turn off mic click volume to 20 percent
exports['pma-voice']:setMicClickOffVolume(20)
```

View File

@@ -0,0 +1,14 @@
## setMicClickOnVolume
## Description
Sets the local players radio turn on mic click volume
## Parameters
* **volume**: the volume to change to between 0 - 100 percent
```lua
-- sets the radio turn on mic click volume to 20 percent
exports['pma-voice']:setMicClickOnVolume(20)
```

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
exports ['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 @@
## removeChannelCheck
## Description
Removes a channel check for radio channel.
## Parameters
* **channel**: The channel to remove the check from.
```lua
-- Example for Removes
exports['pma-voice']:removeChannelCheck(1)
```

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,71 @@
game 'common'
version '7.0.1'
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/init/submix.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 call volume", "$voice_defaultCallVolume", "CV_INT", "60" },
{ "Enable radios", "$voice_enableRadios", "CV_INT", "1" },
{ "Enable calls", "$voice_enableCalls", "CV_INT", "1" },
{ "Enable submix", "$voice_enableSubmix", "CV_INT", "1" },
{ "Enable radio animation", "$voice_enableRadioAnim", "CV_INT", "0" },
{ "Radio key", "$voice_defaultRadio", "CV_STRING", "LMENU" },
{ "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,151 @@
voiceData = {}
radioData = {}
callData = {}
local mappedChannels = {}
function firstFreeChannel()
for i = 1, 2048 do
if not mappedChannels[i] then
return i
end
end
return 0
end
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('call', GetConvarInt('voice_defaultCallVolume', 60), true)
plyState:set('submix', nil, 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
local assignedChannel = firstFreeChannel()
plyState:set('assignedChannel', assignedChannel, true)
if assignedChannel ~= 0 then
mappedChannels[assignedChannel] = source
logger.verbose('[reuse] Assigned %s to channel %s', source, assignedChannel)
else
logger.error('[reuse] Failed to find a free channel for %s', source)
end
end
CreateThread(function()
local plyTbl = GetPlayers()
for i = 1, #plyTbl do
local ply = tonumber(plyTbl[i])
voiceData[ply] = defaultTable(ply)
end
Wait(5000)
local nativeAudio = GetConvar('voice_useNativeAudio', 'not-set')
local _3dAudio = GetConvar('voice_use3dAudio', 'not-set')
local _2dAudio = GetConvar('voice_use2dAudio', 'not-set')
local sendingRangeOnly = GetConvar('voice_useSendingRangeOnly', 'not-set')
local gameVersion = GetConvar('gamename', 'fivem')
-- handle no convars being set (default drag n' drop)
if
nativeAudio == 'not-set'
and _3dAudio == 'not-set'
and _2dAudio == 'not-set'
then
SetConvarReplicated('voice_useNativeAudio', 'true')
logger.info('No voice mod detected, defaulting to \'setr voice_useNativeAudio true\'')
elseif sendingRangeOnly == 'true' then
logger.warn(
"It's recommended to have 'voice_useSendingRangeOnly' set to false, you can do that with 'setr voice_useSendingRangeOnly false', this makes clients ignore the position information sent from the client.")
end
local radioVolume = GetConvarInt("voice_defaultRadioVolume", 30)
local callVolume = GetConvarInt("voice_defaultCallVolume", 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
callVolume == 0 or callVolume == 1
then
SetConvarReplicated("voice_defaultRadioVolume", 30)
SetConvarReplicated("voice_defaultCallVolume", 60)
for i = 1, 5 do
Wait(5000)
logger.warn(
"`voice_defaultRadioVolume` or `voice_defaultCallVolume` 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
local mappedChannel = Player(source).state.assignedChannel
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
if mappedChannel then
mappedChannels[mappedChannel] = nil
logger.verbose('[reuse] Unassigned %s from channel %s', source, mappedChannel)
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,76 @@
--- 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('[call] 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('[call] 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] = true
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_enableCalls', 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)

View File

@@ -0,0 +1,193 @@
local msgpack_pack_args = msgpack.pack_args
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)
--- removes any check for the channel
---@param channel number the channel to add a check to
function removeChannelCheck(channel)
local channelType = type(channel)
if channelType ~= "number" then
error(("'channel' expected 'number' got '%s'"):format(channelType))
end
radioChecks[channel] = nil
logger.info("%s removed check for channel %s", GetInvokingResource(), channel)
end
exports("removeChannelCheck", removeChannelCheck)
local function radioNameGetter_orig(source)
return GetPlayerName(source)
end
local radioNameGetter = radioNameGetter_orig
--- triggers an event for all of the players in the table while only doing msgpack
--- serialization once
local function triggerEventForRadioChannel(eventName, radioTbl, ...)
local payload = msgpack_pack_args(...)
for player, _ in pairs(radioTbl) do
TriggerClientEventInternal(eventName, player, payload, payload:len())
end
end
--- 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
---@return boolean wasAdded if the player was successfuly added to the radio channel, or if it failed.
function addPlayerToRadio(source, radioChannel)
if not canJoinChannel(source, radioChannel) then
-- remove the player from the radio client side
TriggerClientEvent("pma-voice:radioChangeRejected", source)
TriggerClientEvent('pma-voice:removePlayerFromRadio', source, source)
return false
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)
triggerEventForRadioChannel('pma-voice:addPlayerToRadio', radioData[radioChannel], source, plyName)
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)
return true
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 {}
triggerEventForRadioChannel('pma-voice:removePlayerFromRadio', radioData[radioChannel], source)
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
if radioChannel ~= 0 then
if plyVoice.radio > 0 then
removePlayerFromRadio(source, plyVoice.radio)
end
local wasAdded = addPlayerToRadio(source, radioChannel)
Player(source).state.radioChannel = wasAdded and radioChannel or 0
elseif radioChannel == 0 then
removePlayerFromRadio(source, plyVoice.radio)
Player(source).state.radioChannel = 0
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)
triggerEventForRadioChannel('pma-voice:setTalkingOnRadio', radioTbl, source, talking)
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(radioRef, 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,27 @@
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[GetCurrentResourceName()].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]);
delete 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,127 @@
Cfg = {}
voiceTarget = 1
gameVersion = GetGameName()
-- these are just here to satisfy linting
if not IsDuplicityVersion() then
LocalPlayer = LocalPlayer
playerServerId = GetPlayerServerId(PlayerId())
if gameVersion == "redm" then
function CreateAudioSubmix(name)
return Citizen.InvokeNative(0x658d2bc8, name, Citizen.ResultAsInteger())
end
function AddAudioSubmixOutput(submixId, outputSubmixId)
Citizen.InvokeNative(0xAC6E290D, submixId, outputSubmixId)
end
function MumbleSetSubmixForServerId(serverId, submixId)
Citizen.InvokeNative(0xFE3A3054, serverId, submixId)
end
function SetAudioSubmixEffectParamFloat(submixId, effectSlot, paramIndex, paramValue)
Citizen.InvokeNative(0x9A209B3C, submixId, effectSlot, paramIndex, paramValue)
end
function SetAudioSubmixEffectParamInt(submixId, effectSlot, paramIndex, paramValue)
Citizen.InvokeNative(0x77FAE2B8, submixId, effectSlot, paramIndex, paramValue)
end
function SetAudioSubmixEffectRadioFx(submixId, effectSlot)
Citizen.InvokeNative(0xAAA94D53, submixId, effectSlot)
end
function SetAudioSubmixOutputVolumes(submixId, outputSlot, frontLeftVolume, frontRightVolume, rearLeftVolume,
rearRightVolume, channel5Volume, channel6Volume)
Citizen.InvokeNative(0x825DC0D1, submixId, outputSlot, frontLeftVolume, frontRightVolume, rearLeftVolume,
rearRightVolume, channel5Volume, channel6Volume)
end
end
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
--- does a type check and errors if an invalid type is sent
---@param ... table a table with the variable being the first argument and the expected type being the second
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}

View File

@@ -0,0 +1 @@
<!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"><link rel="icon" href="favicon.ico"><title>voice-ui</title><link href="css/app.css" rel="preload" as="style"><link href="js/app.js" rel="preload" as="script"><link href="js/chunk-vendors.js" rel="preload" as="script"><link href="css/app.css" rel="stylesheet"></head><body><noscript><strong>We're sorry but voice-ui doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div><script src="js/chunk-vendors.js"></script><script src="js/app.js"></script></body></html>

View File

@@ -0,0 +1,2 @@
(function(e){function o(o){for(var t,a,r=o[0],d=o[1],l=o[2],s=0,b=[];s<r.length;s++)a=r[s],Object.prototype.hasOwnProperty.call(i,a)&&i[a]&&b.push(i[a][0]),i[a]=0;for(t in d)Object.prototype.hasOwnProperty.call(d,t)&&(e[t]=d[t]);u&&u(o);while(b.length)b.shift()();return c.push.apply(c,l||[]),n()}function n(){for(var e,o=0;o<c.length;o++){for(var n=c[o],t=!0,r=1;r<n.length;r++){var d=n[r];0!==i[d]&&(t=!1)}t&&(c.splice(o--,1),e=a(a.s=n[0]))}return e}var t={},i={app:0},c=[];function a(o){if(t[o])return t[o].exports;var n=t[o]={i:o,l:!1,exports:{}};return e[o].call(n.exports,n,n.exports,a),n.l=!0,n.exports}a.m=e,a.c=t,a.d=function(e,o,n){a.o(e,o)||Object.defineProperty(e,o,{enumerable:!0,get:n})},a.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},a.t=function(e,o){if(1&o&&(e=a(e)),8&o)return e;if(4&o&&"object"===typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(a.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&o&&"string"!=typeof e)for(var t in e)a.d(n,t,function(o){return e[o]}.bind(null,t));return n},a.n=function(e){var o=e&&e.__esModule?function(){return e["default"]}:function(){return e};return a.d(o,"a",o),o},a.o=function(e,o){return Object.prototype.hasOwnProperty.call(e,o)},a.p="";var r=window["webpackJsonp"]=window["webpackJsonp"]||[],d=r.push.bind(r);r.push=o,r=r.slice();for(var l=0;l<r.length;l++)o(r[l]);var u=d;c.push([0,"chunk-vendors"]),n()})({0:function(e,o,n){e.exports=n("56d7")},"0154":function(e,o,n){},"56d7":function(e,o,n){"use strict";n.r(o);var t=n("7edb");const i=Object(t["d"])("audio",{id:"audio_on",src:"mic_click_on.ogg"},null,-1),c=Object(t["d"])("audio",{id:"audio_off",src:"mic_click_off.ogg"},null,-1),a={key:0,class:"voiceInfo"};function r(e,o,n,r,d,l){return Object(t["f"])(),Object(t["c"])("body",null,[i,c,r.voice.uiEnabled?(Object(t["f"])(),Object(t["c"])("div",a,[0!==r.voice.callInfo?(Object(t["f"])(),Object(t["c"])("p",{key:0,class:Object(t["e"])({talking:r.voice.talking})}," [Call] ",2)):Object(t["b"])("",!0),r.voice.radioEnabled&&0!==r.voice.radioChannel?(Object(t["f"])(),Object(t["c"])("p",{key:1,class:Object(t["e"])({talking:r.voice.usingRadio})},Object(t["h"])(r.voice.radioChannel)+" Mhz [Radio] ",3)):Object(t["b"])("",!0),r.voice.voiceModes.length?(Object(t["f"])(),Object(t["c"])("p",{key:2,class:Object(t["e"])({talking:r.voice.talking})},Object(t["h"])(r.voice.voiceModes[r.voice.voiceMode][1])+" [Range] ",3)):Object(t["b"])("",!0)])):Object(t["b"])("",!0)])}var d={name:"App",setup(){const e=Object(t["g"])({uiEnabled:!0,voiceModes:[],voiceMode:0,radioChannel:0,radioEnabled:!0,usingRadio:!1,callInfo:0,talking:!1});return window.addEventListener("message",(function(o){const n=o.data;if(void 0!==n.uiEnabled&&(e.uiEnabled=n.uiEnabled),void 0!==n.voiceModes){e.voiceModes=JSON.parse(n.voiceModes);let o=[...e.voiceModes];o.push([0,"Custom"]),e.voiceModes=o}if(void 0!==n.voiceMode&&(e.voiceMode=n.voiceMode),void 0!==n.radioChannel&&(e.radioChannel=n.radioChannel),void 0!==n.radioEnabled&&(e.radioEnabled=n.radioEnabled),void 0!==n.callInfo&&(e.callInfo=n.callInfo),void 0!==n.usingRadio&&n.usingRadio!==e.usingRadio&&(e.usingRadio=n.usingRadio),void 0===n.talking||e.usingRadio||(e.talking=n.talking),n.sound&&e.radioEnabled&&0!==e.radioChannel){let e=document.getElementById(n.sound);e.load(),e.volume=n.volume,e.play().catch(e=>{})}})),fetch(`https://${GetParentResourceName()}/uiReady`,{method:"POST"}),{voice:e}}},l=(n("9253"),n("85dd")),u=n.n(l);const s=u()(d,[["render",r]]);var b=s;Object(t["a"])(b).mount("#app")},9253:function(e,o,n){"use strict";n("0154")}});
//# sourceMappingURL=app.js.map

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"
}
}

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",
}