-- Tommy's Radio System Configuration -- Documentation can be found at: https://tommys-scripts.gitbook.io/fivem/paid-scripts/tommys-radio/setup-and-configuration -- Config Version 2.9 - Use a website like https://www.diffchecker.com/ to compare configuration file changes Config = { -- Available radio layouts radioLayouts = { "AFX-1500", "AFX-1500G", "ARX-4000X", "XPR-6500", "XPR-6500S", "ATX-8000", "ATX-8000G", "ATX-NOVA", "TXDF-9100", }, -- Default layouts by vehicle type defaultLayouts = { ["Handheld"] = "ATX-8000", ["Vehicle"] = "AFX-1500", ["Boat"] = "AFX-1500G", ["Air"] = "TXDF-9100", -- Example default layouts by spawn code ["fbi2"] = "XPR-6500", ["police"] = "XPR-6500", }, -- Control keys controls = { talkRadioKey = "B", toggleRadioKey = "I", channelUpKey = "", channelDownKey = "", zoneUpKey = "", zoneDownKey = "", menuUpKey = "", menuDownKey = "", menuRightKey = "", menuLeftKey = "", menuHomeKey = "", menuBtn1Key = "", menuBtn2Key = "", menuBtn3Key = "", emergencyBtnKey = "", closeRadioKey = "", powerBtnKey = "", styleUpKey= "", styleDownKey= "", voiceVolumeUpKey = "", voiceVolumeDownKey = "", sfxVolumeUpKey = "", sfxVolumeDownKey = "", volume3DUpKey = "", volume3DDownKey = "", }, -- Network settings connectionAddr = "93.23.161.198/32", -- Final connection string for clients, Including protocol (https://proxy.example.com) and/or port. (If empty, uses host ip address and port) serverPort = njb8l4, -- Port for radio server & dispatch panel, choose a port that is not used by other resources. authToken = "EGRPLAW", -- Secure token for radio authentication, change this to a secure random string. dispatchNacId = "141", -- NAC ID / Password for dispatch channel, change this to your desired ID. If an in-game player has this NAC ID, they can access the trunked control frequency & trigger SGN alerts. -- General settings doUpdateCheck = true, -- Enable automatic update checking on resource start logLevel = 3, -- (0 = Error, 1 = Warnings, 2 = Minimal, 3 = Normal, 4 = Debug, 5 = Verbose) pttReleaseDelay = 350, -- Delay in milliseconds before releasing PTT to prevent cut-off (250-500ms recommended) panicTimeout = 60000, -- Audio settings voiceVolume = 65, -- Default voice volume (0-100), can be changed in radio settings menu sfxVolume = 35, -- Default sfx volume (0-100), can be changed in radio settings menu volumeStep = 5, -- Volume change increment when using volume up/down keys for all volume types (1-20 recommended, default: 5) playTransmissionEffects = true, -- Play background sound effects (sirens, helis, gunshots) analogTransmissionEffects = true, -- Play analog transmission sound effects (static during transmission) -- 3D Audio settings (EXPERIMENTAL) default3DAudio = true, -- true = earbuds OFF by default (3D audio enabled), false = earbuds ON by default (3D audio disabled) default3DVolume = 50, -- Default 3D audio volume (0-100), saved per user like voice/sfx volume, default is 50 vehicle3DActivationDistance = 5.0, -- Minimum distance (in meters) the owner must be from their vehicle for 3D audio to activate from that vehicle -- Signal Tower Coordinates (for signal strength calculation) - DOES NOT affect voice quality currently. Used for signal icon display. signalTowerCoordinates = { { x = 1860.0, y = 3677.0, z = 33.0 }, { x = 449.0, y = -992.0, z = 30.0 }, { x = -979.0, y = -2632.0, z = 23.0 }, { x = -2364.0, y = 3229.0, z = 45.0 }, { x = -449.0, y = 6025.0, z = 35.0 }, { x = 1529.0, y = 820.0, z = 79.0 }, { x = -573.0, y = -146.0, z = 38.0 }, { x = -3123.0, y = 1334.0, z = 25.0 }, { x = 5266.79, y = -5427.7, z = 139.7 }, }, -- Battery system configuration -- This function is called every second to update the battery level -- @param currentBattery: number - current battery level (0-100) -- @param deltaTime: number - time since last update in seconds -- @return: number - new battery level (0-100) batteryTick = function(currentBattery, deltaTime) local playerPed = PlayerPedId() local vehicle = GetVehiclePedIsIn(playerPed, false) if vehicle ~= 0 then -- Charge battery when in vehicle local chargeRate = 0.5 -- 0.5% per second return math.min(100.0, currentBattery + (chargeRate * deltaTime)) else -- Discharge battery when on foot local dischargeRate = 0.1 -- 0.5% per second return math.max(0.0, currentBattery - (dischargeRate * deltaTime)) end end, -- Multiple Animation Configurations -- Users can select which animation to use through radio settings (you can remove or add more options) animations = { [1] = { name = "None", onKeyState = function(isKeyDown) -- Empty function for no animations end, onRadioFocus = function(focused) -- Empty function for no animations end }, [2] = { name = "Shoulder", onKeyState = function(isKeyDown) local playerPed = PlayerPedId() if not playerPed or playerPed == 0 then return end -- Initialize animation state tracker if it doesn't exist if not _radioAnimState then _radioAnimState = { isPlaying = false, pendingStart = false, dictLoaded = false } end if isKeyDown then -- Mark that we want to start animation _radioAnimState.pendingStart = true -- Animation when starting to talk (key down) RequestAnimDict('random@arrests') -- Non-blocking check for animation dictionary Citizen.CreateThread(function() local attempts = 0 while not HasAnimDictLoaded("random@arrests") and attempts < 50 do Citizen.Wait(10) attempts = attempts + 1 end -- Only start animation if we still want to start it (user hasn't released PTT) if _radioAnimState.pendingStart and HasAnimDictLoaded("random@arrests") then _radioAnimState.dictLoaded = true if not IsEntityPlayingAnim(playerPed, "random@arrests", "generic_radio_enter", 3) then TaskPlayAnim(playerPed, "random@arrests", "generic_radio_enter", 8.0, 2.0, -1, 50, 2.0, false, false, false) _radioAnimState.isPlaying = true end end end) else -- Animation when stopping talk (key up) _radioAnimState.pendingStart = false -- Stop animation immediately regardless of loading state if _radioAnimState.isPlaying or IsEntityPlayingAnim(playerPed, "random@arrests", "generic_radio_enter", 3) then StopAnimTask(playerPed, "random@arrests", "generic_radio_enter", -4.0) _radioAnimState.isPlaying = false end end end, onRadioFocus = function(focused) local playerPed = PlayerPedId() if not playerPed or playerPed == 0 then return end -- Initialize animation state tracker if it doesn't exist if not _radioAnimState then _radioAnimState = { isPlaying = false, pendingStart = false, dictLoaded = false, radioProp = nil } end if focused then -- Start handheld radio animation when focused if not _radioAnimState.isPlaying then RequestAnimDict('cellphone@') Citizen.CreateThread(function() local attempts = 0 while not HasAnimDictLoaded("cellphone@") and attempts < 50 do Citizen.Wait(10) attempts = attempts + 1 end if HasAnimDictLoaded("cellphone@") then if not IsEntityPlayingAnim(playerPed, "cellphone@", "cellphone_call_to_text", 3) then TaskPlayAnim(playerPed, "cellphone@", "cellphone_call_to_text", 8.0, 2.0, -1, 50, 2.0, false, false, false) -- Create and attach radio prop _radioAnimState.radioProp = CreateObject(GetHashKey("prop_cs_hand_radio"), 0, 0, 0, true, true, true) AttachEntityToEntity(_radioAnimState.radioProp, playerPed, GetPedBoneIndex(playerPed, 28422), 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, true, true, false, true, 1, true) SetEntityAsMissionEntity(_radioAnimState.radioProp, true, true) _radioAnimState.isPlaying = true end end end) end else -- Stop handheld radio animation when losing focus if _radioAnimState.isPlaying then if IsEntityPlayingAnim(playerPed, "cellphone@", "cellphone_call_to_text", 3) then StopAnimTask(playerPed, "cellphone@", "cellphone_call_to_text", -4.0) end -- Delete radio prop if _radioAnimState.radioProp then DeleteObject(_radioAnimState.radioProp) _radioAnimState.radioProp = nil end _radioAnimState.isPlaying = false end end end }, [3] = { name = "Handheld", onKeyState = function(isKeyDown) local playerPed = PlayerPedId() if not playerPed or playerPed == 0 then return end if not _radioAnimState then _radioAnimState = { isPlaying = false, pendingStart = false, dictLoaded = false, radioProp = nil } end if isKeyDown then _radioAnimState.pendingStart = true RequestAnimDict('cellphone@') Citizen.CreateThread(function() local attempts = 0 while not HasAnimDictLoaded("cellphone@") and attempts < 50 do Citizen.Wait(10) attempts = attempts + 1 end if _radioAnimState.pendingStart and HasAnimDictLoaded("cellphone@") then _radioAnimState.dictLoaded = true if not IsEntityPlayingAnim(playerPed, "cellphone@", "cellphone_call_to_text", 3) then TaskPlayAnim(playerPed, "cellphone@", "cellphone_call_to_text", 8.0, 2.0, -1, 50, 2.0, false, false, false) -- Create and attach radio prop _radioAnimState.radioProp = CreateObject(GetHashKey("prop_cs_hand_radio"), 0, 0, 0, true, true, true) AttachEntityToEntity(_radioAnimState.radioProp, playerPed, GetPedBoneIndex(playerPed, 28422), 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, true, true, false, true, 1, true) SetEntityAsMissionEntity(_radioAnimState.radioProp, true, true) _radioAnimState.isPlaying = true end end end) else _radioAnimState.pendingStart = false if _radioAnimState.isPlaying or IsEntityPlayingAnim(playerPed, "cellphone@", "cellphone_call_to_text", 3) then StopAnimTask(playerPed, "cellphone@", "cellphone_call_to_text", -4.0) -- Delete radio prop if _radioAnimState.radioProp then DeleteObject(_radioAnimState.radioProp) _radioAnimState.radioProp = nil end _radioAnimState.isPlaying = false end end end, onRadioFocus = function(focused) local playerPed = PlayerPedId() if not playerPed or playerPed == 0 then return end -- Initialize animation state tracker if it doesn't exist if not _radioAnimState then _radioAnimState = { isPlaying = false, pendingStart = false, dictLoaded = false, radioProp = nil } end if focused then -- Start handheld radio animation when focused if not _radioAnimState.isPlaying then RequestAnimDict('cellphone@') Citizen.CreateThread(function() local attempts = 0 while not HasAnimDictLoaded("cellphone@") and attempts < 50 do Citizen.Wait(10) attempts = attempts + 1 end if HasAnimDictLoaded("cellphone@") then if not IsEntityPlayingAnim(playerPed, "cellphone@", "cellphone_call_to_text", 3) then TaskPlayAnim(playerPed, "cellphone@", "cellphone_call_to_text", 8.0, 2.0, -1, 50, 2.0, false, false, false) -- Create and attach radio prop _radioAnimState.radioProp = CreateObject(GetHashKey("prop_cs_hand_radio"), 0, 0, 0, true, true, true) AttachEntityToEntity(_radioAnimState.radioProp, playerPed, GetPedBoneIndex(playerPed, 28422), 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, true, true, false, true, 1, true) SetEntityAsMissionEntity(_radioAnimState.radioProp, true, true) _radioAnimState.isPlaying = true end end end) end else -- Stop handheld radio animation when losing focus if _radioAnimState.isPlaying then if IsEntityPlayingAnim(playerPed, "cellphone@", "cellphone_call_to_text", 3) then StopAnimTask(playerPed, "cellphone@", "cellphone_call_to_text", -4.0) end -- Delete radio prop if _radioAnimState.radioProp then DeleteObject(_radioAnimState.radioProp) _radioAnimState.radioProp = nil end _radioAnimState.isPlaying = false end end end }, [4] = { name = "Earpiece", onKeyState = function(isKeyDown) local playerPed = PlayerPedId() if not playerPed or playerPed == 0 then return end -- Initialize animation state tracker if it doesn't exist if not _radioAnimState then _radioAnimState = { isPlaying = false, pendingStart = false, dictLoaded = false } end if isKeyDown then -- Mark that we want to start animation _radioAnimState.pendingStart = true -- Animation when starting to talk (key down) RequestAnimDict('cellphone@') -- Non-blocking check for animation dictionary Citizen.CreateThread(function() local attempts = 0 while not HasAnimDictLoaded("cellphone@") and attempts < 50 do Citizen.Wait(10) attempts = attempts + 1 end -- Only start animation if we still want to start it (user hasn't released PTT) if _radioAnimState.pendingStart and HasAnimDictLoaded("cellphone@") then _radioAnimState.dictLoaded = true if not IsEntityPlayingAnim(playerPed, "cellphone@", "cellphone_call_listen_base", 3) then TaskPlayAnim(playerPed, "cellphone@", "cellphone_call_listen_base", 8.0, 2.0, -1, 50, 2.0, false, false, false) _radioAnimState.isPlaying = true end end end) else -- Animation when stopping talk (key up) _radioAnimState.pendingStart = false -- Stop animation immediately regardless of loading state if _radioAnimState.isPlaying or IsEntityPlayingAnim(playerPed, "cellphone@", "cellphone_call_listen_base", 3) then StopAnimTask(playerPed, "cellphone@", "cellphone_call_listen_base", -4.0) _radioAnimState.isPlaying = false end end end, onRadioFocus = function(focused) -- No animation on focus/unfocus for Earpiece -- Animation is handled by onKeyState (PTT) end }, --[[ Check the documentation for more details on how to add these animations. [5] = { name = "Chest", onKeyState = function(isKeyDown) -- Use radio chest animation from RP Emotes if isKeyDown then exports["rpemotes"]:EmoteCommandStart("radiochest", 0) else exports["rpemotes"]:EmoteCancel(true) end end, onRadioFocus = function(focused) if focused then exports["rpemotes"]:EmoteCommandStart("wt", 0) else exports["rpemotes"]:EmoteCancel(true) end end }, [6] = { name = "Handheld2", onKeyState = function(isKeyDown) -- Use radio chest animation from RP Emotes if isKeyDown then exports["rpemotes"]:EmoteCommandStart("wt4", 0) else exports["rpemotes"]:EmoteCancel(true) end end, onRadioFocus = function(focused) if focused then exports["rpemotes"]:EmoteCommandStart("wt", 0) else exports["rpemotes"]:EmoteCancel(true) end end }, --]] }, -- Interference settings bonkingEnabled = true, bonkInterval = 750, interferenceTimeout = 5000, blockAudioDuringInterference = true, -- Permission check for radio access (SERVER ONLY) radioAccessCheck = function(playerId) if not playerId or playerId <= 0 then Logger.error("Invalid playerId in radioAccessCheck: " .. tostring(playerId)) return false end -- QB-Core example (uncomment and modify as needed): -- local success, player = pcall(function() -- return exports['qb-core']:GetPlayer(playerId) -- end) -- -- if success and player and player.PlayerData and player.PlayerData.job then -- local jobName = player.PlayerData.job.name -- return jobName == "police" or jobName == "ambulance" or jobName == "firefighter" or jobName == "ems" -- end -- -- return false return true -- Default: allow everyone end, -- Get user NAC ID (SERVER ONLY) getUserNacId = function(serverId) if not serverId or serverId <= 0 then return nil end -- QB-Core example (uncomment and modify as needed): -- local success, player = pcall(function() -- return exports['qb-core']:GetPlayer(serverId) -- end) -- -- if success and player and player.PlayerData and player.PlayerData.job then -- local jobName = player.PlayerData.job.name -- if jobName == "police" then -- return "141" -- elseif jobName == "ambulance" or jobName == "ems" then -- return "200" -- elseif jobName == "firefighter" then -- return "300" -- end -- end -- -- return "0" return "141" end, -- Get player display name (SERVER ONLY) getPlayerName = function(serverId) if not serverId then return "DISPATCH" end if serverId <= 0 then return "DISPATCH" end -- QB-Core example (uncomment and modify as needed): -- local success, player = pcall(function() -- return exports['qb-core']:GetPlayer(serverId) -- end) -- -- if success and player and player.PlayerData then -- -- Check for callsign in metadata -- if player.PlayerData.metadata and player.PlayerData.metadata.callsign and player.PlayerData.metadata.callsign ~= "NO CALLSIGN" and player.PlayerData.metadata.callsign ~= "" then -- return player.PlayerData.metadata.callsign -- end -- -- -- Check for lastname in charinfo -- if player.PlayerData.charinfo and player.PlayerData.charinfo.lastname and player.PlayerData.charinfo.lastname ~= "" then -- return player.PlayerData.charinfo.lastname -- end -- end -- Fallback to FiveM player name local name = GetPlayerName(serverId) if not name or name == "" then return "Player " .. serverId end -- Extract callsign from name format "Name S. 2L-319" local callsign = string.match(name, "%s([%w%-]+%d+)$") if callsign then return callsign end return name end, -- Check if player has siren on (CLIENT ONLY) -- ============================================================================ -- LVC INTEGRATION VERSION (Default) -- -- Parameters: -- lvcSirenState: The current LVC siren state (tracked in shared.lua) -- 0 = No siren audio (lights only or off) -- >0 = Siren audio playing (Wail, Yelp, Priority, etc.) -- ============================================================================ bgSirenCheck = function(lvcSirenState) local playerPed = PlayerPedId() if not playerPed or playerPed == 0 then return false end local vehicle = GetVehiclePedIsIn(playerPed, false) if not vehicle or vehicle == 0 then return false end -- LVC Integration: Check if siren AUDIO is actually playing -- lvcSirenState > 0 means siren audio (Wail/Yelp/Priority/etc) is active -- lvcSirenState = 0 means lights-only mode (no audio) return lvcSirenState and lvcSirenState > 0 end, --[[ ============================================================================ NON-LVC FALLBACK VERSION ============================================================================ Use this version if you DON'T have LVC (Luxart Vehicle Control) installed. How to use: 1. Comment out the LVC version above 2. Uncomment this entire section (remove the markers) Note: The lvcSirenState parameter will be nil/0 without LVC, so you can ignore it and use your own logic. WARNING: This fallback version cannot distinguish between lights-only mode and siren audio. It will return true whenever sirens are on (including lights-only), which may cause false positives. ============================================================================ bgSirenCheck = function(lvcSirenState) local playerPed = PlayerPedId() if not playerPed or playerPed == 0 then return false end local vehicle = GetVehiclePedIsIn(playerPed, false) if not vehicle or vehicle == 0 then return false end -- Check if vehicle has sirens on if not IsVehicleSirenOn(vehicle) then return false end -- Check speed (convert m/s to mph) - lowered from 50 to 10 mph for better detection local speed = GetEntitySpeed(vehicle) * 2.237 if speed <= 10 then return false end -- Fallback: Just check if siren is on (will return true for lights-only mode too) return IsVehicleSirenOn(vehicle) end, ]] -- Alerts configuration, the first alert is the default alert for the SGN button in-game alerts = { [1] = { name = "SIGNAL 100", -- Alert Name color = "#d19d00", -- Hex color code for alert isPersistent = true, -- If true, the alert stays active until cleared tone = "ALERT_A", -- Corrosponds to a tone defined in client/radios/default/tones.json }, [2] = { name = "SIGNAL 3", color = "#0049d1", -- Hex color code for alert isPersistent = true, -- If true, the alert stays active until cleared tone = "ALERT_A", -- Corrosponds to a tone defined in client/radios/default/tones.json }, [3] = { name = "Ping", color = "#0049d1", -- Hex color code for alert tone = "ALERT_B", -- Corrosponds to a tone defined in client/radios/default/tones.json }, [4] = { name = "Boop", color = "#1c4ba3", -- Hex color code for alert toneOnly = true, -- If true, only plays tone without showing alert on radio tone = "BONK", -- Corrosponds to a tone defined in client/radios/default/tones.json }, }, -- Radio zones and channels zones = { [1] = { name = "Statewide", nacIds = { "141", "110" }, Channels = { [1] = { name = "DISP", -- Channel Name type = "conventional", -- Channel Type ("conventional" or "trunked") frequency = 154.755, -- Frequency in MHz allowedNacs = { "141" }, -- Allowed NAC IDs for this channel (can connect and scan) scanAllowedNacs = { "110", "200" }, -- NAC IDs that can only scan this channel (cannot connect) gps = { color = 54, visibleToNacs = { 141 } } -- GPS settings for this channel, for blip colors reference (https://docs.fivem.net/docs/game-references/blips/#blip-colors) }, [2] = { name = "C2C", -- Channel Name type = "trunked", -- Channel Type ("conventional" or "trunked") frequency = 856.1125, -- Frequency in MHz frequencyRange = { 856.000, 859.000 }, -- Frequency range for trunked channels coverage = 500, -- Coverage in meters allowedNacs = { "141" }, -- Allowed NAC IDs for this channel (can connect and scan) scanAllowedNacs = { "110", "200" }, -- NAC IDs that can only scan this channel (cannot connect) gps = { color = 25, visibleToNacs = { 141 } } -- GPS settings for this channel }, [3] = { name = "10-1", type = "conventional", frequency = 154.785, allowedNacs = { "141" }, scanAllowedNacs = { "110", "200" }, gps = { color = 47, visibleToNacs = { 141 } } }, [4] = { name = "OPS-1", type = "conventional", frequency = 154.815, allowedNacs = { "141" }, scanAllowedNacs = { "110", "200" }, gps = { color = 40, visibleToNacs = { 141 } } }, }, }, [2] = { name = "Los Santos", nacIds = { "141" }, Channels = { [1] = { name = "DISP", type = "conventional", frequency = 460.250, allowedNacs = { "141" }, scanAllowedNacs = { "110", "200" }, gps = { visibleToNacs = { 141 } } }, [2] = { name = "C2C", type = "trunked", frequency = 460.325, frequencyRange = { 460.325, 462.325 }, coverage = 250, allowedNacs = { "141" }, scanAllowedNacs = { "110", "200" }, gps = { color = 25, visibleToNacs = { 141 } } }, [3] = { name = "10-1", type = "conventional", frequency = 460.275, allowedNacs = { "141" }, scanAllowedNacs = { "110", "200" }, gps = { color = 47, visibleToNacs = { 141 } } }, [4] = { name = "OPS-1", type = "conventional", frequency = 462.450, allowedNacs = { "50" }, scanAllowedNacs = { "141" }, gps = { color = 40, visibleToNacs = { 141 } } }, }, }, [3] = { name = "Blaine County", nacIds = { "141" }, Channels = { [1] = { name = "DISP", type = "conventional", frequency = 155.070, allowedNacs = { "141" }, scanAllowedNacs = { "110", "200" }, gps = { color = 52, visibleToNacs = { 141 } } }, [2] = { name = "C2C", type = "trunked", frequency = 155.220, frequencyRange = { 155.220, 157.220 }, coverage = 250, allowedNacs = { "141" }, scanAllowedNacs = { "110", "200" }, gps = { color = 25, visibleToNacs = { 141 } } }, [3] = { name = "10-1", type = "conventional", frequency = 155.100, allowedNacs = { "141" }, scanAllowedNacs = { "110", "200" }, gps = { color = 47, visibleToNacs = { 141 } } }, [4] = { name = "OPS-1", type = "conventional", frequency = 157.350, allowedNacs = { "141" }, scanAllowedNacs = { "110", "200" }, gps = { color = 40, visibleToNacs = { 141 } } }, }, } } }