diff --git a/CTLD.lua b/CTLD.lua index 4d41bac..8cca07c 100644 --- a/CTLD.lua +++ b/CTLD.lua @@ -26,7 +26,7 @@ ctld = {} -- DONT REMOVE! ctld.Id = "CTLD - " --- Version. -ctld.Version = "20210617.01" +ctld.Version = "20210617.02" -- debug level, specific to this module ctld.Debug = true @@ -2341,7 +2341,7 @@ function ctld.unloadTroops(_args) else -- troops must be onboard to get here - if _zone.inZone == true then + if _zone.inZone == true then if _troops then ctld.displayMessageToGroup(_heli, "Dropped troops back to base", 20) @@ -5115,9 +5115,32 @@ ctld.jtacCurrentTargets = {} ctld.jtacRadioAdded = {} --keeps track of who's had the radio command added ctld.jtacGeneratedLaserCodes = {} -- keeps track of generated codes, cycles when they run out ctld.jtacLaserPointCodes = {} +ctld.jtacRadioData = {} +function ctld.JTACAutoLase(_jtacGroupName, _laserCode, _smoke, _lock, _colour, _radio) + ctld.logDebug(string.format("ctld.JTACAutoLase(_jtacGroupName=%s, _laserCode=%s", ctld.p(_jtacGroupName), ctld.p(_laserCode))) -function ctld.JTACAutoLase(_jtacGroupName, _laserCode, _smoke, _lock, _colour) + local _radio = _radio + if not _radio then + _radio = {} + if _laserCode then + local _laserCode = tonumber(_laserCode) + if _laserCode and _laserCode >= 1111 and _laserCode <= 1688 then + local _laserB = math.floor((_laserCode - 1000)/100) + local _laserCD = _laserCode - 1000 - _laserB*100 + local _frequency = tostring(30+_laserB+_laserCD*0.05) + ctld.logTrace(string.format("_laserB=%s", ctld.p(_laserB))) + ctld.logTrace(string.format("_laserCD=%s", ctld.p(_laserCD))) + ctld.logTrace(string.format("_frequency=%s", ctld.p(_frequency))) + _radio.freq = _frequency + _radio.mod = "fm" + end + end + end + + if _radio and not _radio.name then + _radio.name = _jtacGroupName + end if ctld.jtacStop[_jtacGroupName] == true then ctld.jtacStop[_jtacGroupName] = nil -- allow it to be started again @@ -5132,6 +5155,7 @@ function ctld.JTACAutoLase(_jtacGroupName, _laserCode, _smoke, _lock, _colour) ctld.jtacLaserPointCodes[_jtacGroupName] = _laserCode + ctld.jtacRadioData[_jtacGroupName] = _radio local _jtacGroup = ctld.getGroup(_jtacGroupName) local _jtacUnit @@ -5148,7 +5172,7 @@ function ctld.JTACAutoLase(_jtacGroupName, _laserCode, _smoke, _lock, _colour) ctld.cleanupJTAC(_jtacGroupName) env.info(_jtacGroupName .. ' in Transport - Waiting 10 seconds') - timer.scheduleFunction(ctld.timerJTACAutoLase, { _jtacGroupName, _laserCode, _smoke, _lock, _colour }, timer.getTime() + 10) + timer.scheduleFunction(ctld.timerJTACAutoLase, { _jtacGroupName, _laserCode, _smoke, _lock, _colour, _radio }, timer.getTime() + 10) return end @@ -5157,7 +5181,7 @@ function ctld.JTACAutoLase(_jtacGroupName, _laserCode, _smoke, _lock, _colour) ctld.cleanupJTAC(_jtacGroupName) env.info(_jtacGroupName .. ' in Transport - Waiting 10 seconds') - timer.scheduleFunction(ctld.timerJTACAutoLase, { _jtacGroupName, _laserCode, _smoke, _lock, _colour }, timer.getTime() + 10) + timer.scheduleFunction(ctld.timerJTACAutoLase, { _jtacGroupName, _laserCode, _smoke, _lock, _colour, _radio }, timer.getTime() + 10) return end end @@ -5166,7 +5190,7 @@ function ctld.JTACAutoLase(_jtacGroupName, _laserCode, _smoke, _lock, _colour) if ctld.jtacUnits[_jtacGroupName] ~= nil then - ctld.notifyCoalition("JTAC Group " .. _jtacGroupName .. " KIA!", 10, ctld.jtacUnits[_jtacGroupName].side) + ctld.notifyCoalition("JTAC Group " .. _jtacGroupName .. " KIA!", 10, ctld.jtacUnits[_jtacGroupName].side, _radio) end --remove from list @@ -5179,7 +5203,7 @@ function ctld.JTACAutoLase(_jtacGroupName, _laserCode, _smoke, _lock, _colour) _jtacUnit = _jtacGroup[1] --add to list - ctld.jtacUnits[_jtacGroupName] = { name = _jtacUnit:getName(), side = _jtacUnit:getCoalition() } + ctld.jtacUnits[_jtacGroupName] = { name = _jtacUnit:getName(), side = _jtacUnit:getCoalition(), radio = _radio } -- work out smoke colour if _colour == nil then @@ -5210,7 +5234,7 @@ function ctld.JTACAutoLase(_jtacGroupName, _laserCode, _smoke, _lock, _colour) ctld.cleanupJTAC(_jtacGroupName) env.info(_jtacGroupName .. ' Not Active - Waiting 30 seconds') - timer.scheduleFunction(ctld.timerJTACAutoLase, { _jtacGroupName, _laserCode, _smoke, _lock, _colour }, timer.getTime() + 30) + timer.scheduleFunction(ctld.timerJTACAutoLase, { _jtacGroupName, _laserCode, _smoke, _lock, _colour, _radio }, timer.getTime() + 30) return end @@ -5262,7 +5286,7 @@ function ctld.JTACAutoLase(_jtacGroupName, _laserCode, _smoke, _lock, _colour) local message = _jtacGroupName .. action .. _enemyUnit:getTypeName() local fullMessage = message .. '. CODE: ' .. _laserCode .. ". POSITION: " .. ctld.getPositionString(_enemyUnit) - ctld.notifyCoalition(fullMessage, 10, _jtacUnit:getCoalition()) + ctld.notifyCoalition(fullMessage, 10, _jtacUnit:getCoalition(), _radio, message) -- create smoke if _smoke == true then @@ -5278,7 +5302,7 @@ function ctld.JTACAutoLase(_jtacGroupName, _laserCode, _smoke, _lock, _colour) ctld.laseUnit(_enemyUnit, _jtacUnit, _jtacGroupName, _laserCode) -- env.info('Timer timerSparkleLase '..jtacGroupName.." "..laserCode.." "..enemyUnit:getName()) - timer.scheduleFunction(ctld.timerJTACAutoLase, { _jtacGroupName, _laserCode, _smoke, _lock, _colour }, timer.getTime() + 15) + timer.scheduleFunction(ctld.timerJTACAutoLase, { _jtacGroupName, _laserCode, _smoke, _lock, _colour, _radio }, timer.getTime() + 15) if _smoke == true then @@ -5298,13 +5322,13 @@ function ctld.JTACAutoLase(_jtacGroupName, _laserCode, _smoke, _lock, _colour) ctld.cancelLase(_jtacGroupName) -- env.info('Timer Slow timerSparkleLase '..jtacGroupName.." "..laserCode.." "..enemyUnit:getName()) - timer.scheduleFunction(ctld.timerJTACAutoLase, { _jtacGroupName, _laserCode, _smoke, _lock, _colour }, timer.getTime() + 5) + timer.scheduleFunction(ctld.timerJTACAutoLase, { _jtacGroupName, _laserCode, _smoke, _lock, _colour, _radio }, timer.getTime() + 5) end if targetLost then - ctld.notifyCoalition(_jtacGroupName .. ", target lost.", 10, _jtacUnit:getCoalition()) + ctld.notifyCoalition(_jtacGroupName .. ", target lost.", 10, _jtacUnit:getCoalition(), _radio) elseif targetDestroyed then - ctld.notifyCoalition(_jtacGroupName .. ", target destroyed.", 10, _jtacUnit:getCoalition()) + ctld.notifyCoalition(_jtacGroupName .. ", target destroyed.", 10, _jtacUnit:getCoalition(), _radio) end end @@ -5315,7 +5339,7 @@ end -- used by the timer function function ctld.timerJTACAutoLase(_args) - ctld.JTACAutoLase(_args[1], _args[2], _args[3], _args[4], _args[5]) + ctld.JTACAutoLase(_args[1], _args[2], _args[3], _args[4], _args[5], _args[6]) end function ctld.cleanupJTAC(_jtacGroupName) @@ -5326,11 +5350,42 @@ function ctld.cleanupJTAC(_jtacGroupName) ctld.jtacUnits[_jtacGroupName] = nil ctld.jtacCurrentTargets[_jtacGroupName] = nil + + ctld.jtacRadioData[_jtacGroupName] = nil end -function ctld.notifyCoalition(_message, _displayFor, _side) +--- send a message to the coalition +--- if _radio is set, the message will be read out loud via SRS +function ctld.notifyCoalition(_message, _displayFor, _side, _radio, _shortMessage) + ctld.logDebug(string.format("ctld.notifyCoalition(_message=%s)", ctld.p(_message))) + ctld.logTrace(string.format("_radio=%s", ctld.p(_radio))) + local _shortMessage = _shortMessage + if _shortMessage == nil then + _shortMessage = _message + end + + if STTS and STTS.TextToSpeech and _radio and _radio.freq then + local _freq = _radio.freq + local _modulation = _radio.mod or "FM" + local _volume = _radio.volume or "1.0" + local _name = _radio.name or "JTAC" + local _gender = _radio.gender or "male" + local _culture = _radio.culture or "en-US" + local _voice = _radio.voice + local _googleTTS = _radio.googleTTS or false + ctld.logTrace(string.format("calling STTS.TextToSpeech(%s)", ctld.p(_shortMessage))) + ctld.logTrace(string.format("_freq=%s", ctld.p(_freq))) + ctld.logTrace(string.format("_modulation=%s", ctld.p(_modulation))) + ctld.logTrace(string.format("_volume=%s", ctld.p(_volume))) + ctld.logTrace(string.format("_name=%s", ctld.p(_name))) + ctld.logTrace(string.format("_gender=%s", ctld.p(_gender))) + ctld.logTrace(string.format("_culture=%s", ctld.p(_culture))) + ctld.logTrace(string.format("_voice=%s", ctld.p(_voice))) + ctld.logTrace(string.format("_googleTTS=%s", ctld.p(_googleTTS))) + STTS.TextToSpeech(_shortMessage, _freq, _modulation, _volume, _name, _side, nil, 1, _gender, _culture, _voice, _googleTTS) + end trigger.action.outTextForCoalition(_side, _message, _displayFor) trigger.action.outSoundForCoalition(_side, "radiobeep.ogg") @@ -5552,18 +5607,33 @@ function ctld.findNearestVisibleEnemy(_jtacUnit, _targetType,_distance) end end + local result = nil for _, _enemyUnit in ipairs(_unitList) do local _enemyName = _enemyUnit.unit:getName() + --log.info(string.format("CTLD - checking _enemyName=%s", _enemyName)) + + -- check for air defenses + --log.info(string.format("CTLD - _enemyUnit.unit:getDesc()[attributes]=%s", ctld.p(_enemyUnit.unit:getDesc()["attributes"]))) + local airdefense = (_enemyUnit.unit:getDesc()["attributes"]["Air Defence"] ~= nil) + --log.info(string.format("CTLD - airdefense=%s", tostring(airdefense))) if (_targetType == "vehicle" and ctld.isVehicle(_enemyUnit.unit)) or _targetType == "all" then - return _enemyUnit.unit + if airdefense then + return _enemyUnit.unit + else + result = _enemyUnit.unit + end elseif (_targetType == "troop" and ctld.isInfantry(_enemyUnit.unit)) or _targetType == "all" then - return _enemyUnit.unit + if airdefense then + return _enemyUnit.unit + else + result = _enemyUnit.unit + end end end - return nil + return result end @@ -5698,12 +5768,17 @@ function ctld.getJTACStatus(_args) local _laserCode = ctld.jtacLaserPointCodes[_jtacGroupName] + local _start = _jtacGroupName + if (_jtacDetails.radio) then + _start = _start .. ", available on ".._jtacDetails.radio.freq.." ".._jtacDetails.radio.mod .."," + end + if _laserCode == nil then _laserCode = "UNKNOWN" end if _enemyUnit ~= nil and _enemyUnit:getLife() > 0 and _enemyUnit:isActive() == true then - _message = _message .. "" .. _jtacGroupName .. " targeting " .. _enemyUnit:getTypeName() .. " CODE: " .. _laserCode .. ctld.getPositionString(_enemyUnit) .. "\n" + _message = _message .. "" .. _start .. " targeting " .. _enemyUnit:getTypeName() .. " CODE: " .. _laserCode .. ctld.getPositionString(_enemyUnit) .. "\n" local _list = ctld.listNearbyEnemies(_jtacUnit) @@ -5717,7 +5792,7 @@ function ctld.getJTACStatus(_args) end else - _message = _message .. "" .. _jtacGroupName .. " searching for targets" .. ctld.getPositionString(_jtacUnit) .. "\n" + _message = _message .. "" .. _start .. " searching for targets" .. ctld.getPositionString(_jtacUnit) .. "\n" end end end diff --git a/DCS-SimpleTextToSpeech.lua b/DCS-SimpleTextToSpeech.lua new file mode 100644 index 0000000..2fe1d3d --- /dev/null +++ b/DCS-SimpleTextToSpeech.lua @@ -0,0 +1,215 @@ +--[[ + +DCS-SimpleTextToSpeech +Version 0.4 +Compatible with SRS version 1.9.6.0 + + +DCS Modification Required: + +You will need to edit MissionScripting.lua in DCS World/Scripts/MissionScripting.lua and remove the sanitisation. +To do this remove all the code below the comment - the line starts "local function sanitizeModule(name)" + +Do this without DCS running to allow mission scripts to use os functions. + +*You WILL HAVE TO REAPPLY AFTER EVERY DCS UPDATE* + +USAGE: + +Add this script into the mission as a DO SCRIPT or DO SCRIPT FROM FILE to initialise it + +Make sure to edit the STTS.SRS_PORT and STTS.DIRECTORY to the correct values before adding to the mission. + +Then its as simple as calling the correct function in LUA as a DO SCRIPT or in your own scripts + +Example calls: + +STTS.TextToSpeech("Hello DCS WORLD","251","AM","1.0","SRS",2) + +Arguments in order are: + - Message to say, make sure not to use a newline (\n) ! + - Frequency in MHz + - Modulation - AM/FM + - Volume - 1.0 max, 0.5 half + - Name of the transmitter - ATC, RockFM etc + - Coalition - 0 spectator, 1 red 2 blue + - OPTIONAL - Vec3 Point i.e Unit.getByName("A UNIT"):getPoint() - needs Vec3 for Height! OR null if not needed + - OPTIONAL - Speed -10 to +10 + - OPTIONAL - Gender male, female or neuter + - OPTIONAL - Culture - en-US, en-GB etc + - OPTIONAL - Voice - a specfic voice by name. Run DCS-SR-ExternalAudio.exe with --help to get the ones you can use on the command line + - OPTIONAL - Google TTS - Switch to Google Text To Speech - Requires STTS.GOOGLE_CREDENTIALS path and Google project setup correctly + + This example will say the words "Hello DCS WORLD" on 251 MHz AM at maximum volume with a client called SRS and to the Blue coalition only + +STTS.TextToSpeech("Hello DCS WORLD","251","AM","1.0","SRS",2,null,-5,"male","en-GB") + + This example will say the words "Hello DCS WORLD" on 251 MHz AM at maximum volume with a client called SRS and to the Blue coalition only centered on + the position of the Unit called "A UNIT" + +STTS.TextToSpeech("Hello DCS WORLD","251","AM","1.0","SRS",2,Unit.getByName("A UNIT"):getPoint(),-5,"male","en-GB") + +Arguments in order are: + - FULL path to the MP3 OR OGG to play + - Frequency in MHz - to use multiple separate with a comma - Number of frequencies MUST match number of Modulations + - Modulation - AM/FM - to use multiple + - Volume - 1.0 max, 0.5 half + - Name of the transmitter - ATC, RockFM etc + - Coalition - 0 spectator, 1 red 2 blue + +This will play that MP3 on 255MHz AM & 31 FM at half volume with a client called "Multiple" and to Spectators only + +STTS.PlayMP3("C:\\Users\\Ciaran\\Downloads\\PR-Music.mp3","255,31","AM,FM","0.5","Multiple",0) + +]] + + +STTS = {} +-- FULL Path to the FOLDER containing DCS-SR-ExternalAudio.exe - EDIT TO CORRECT FOLDER +STTS.DIRECTORY = "C:\\Users\\Ciaran\\Dropbox\\Dev\\DCS\\DCS-SRS\\install-build" +STTS.SRS_PORT = 5002 -- LOCAL SRS PORT - DEFAULT IS 5002 +STTS.GOOGLE_CREDENTIALS = "C:\\Users\\Ciaran\\Downloads\\googletts.json" + +-- DONT CHANGE THIS UNLESS YOU KNOW WHAT YOU'RE DOING +STTS.EXECUTABLE = "DCS-SR-ExternalAudio.exe" + +local random = math.random +function STTS.uuid() + local template ='yxxx-xxxxxxxxxxxx' + return string.gsub(template, '[xy]', function (c) + local v = (c == 'x') and random(0, 0xf) or random(8, 0xb) + return string.format('%x', v) + end) +end + +function STTS.round(x, n) + n = math.pow(10, n or 0) + x = x * n + if x >= 0 then x = math.floor(x + 0.5) else x = math.ceil(x - 0.5) end + return x / n +end + +function STTS.getSpeechTime(length,speed,isGoogle) + -- Function returns estimated speech time in seconds + + -- Assumptions for time calc: 100 Words per min, avarage of 5 letters for english word + -- so 5 chars * 100wpm = 500 characters per min = 8.3 chars per second + -- so lengh of msg / 8.3 = number of seconds needed to read it. rounded down to 8 chars per sec + -- map function: (x - in_min) * (out_max - out_min) / (in_max - in_min) + out_min + + local maxRateRatio = 3 + + speed = speed or 1.0 + isGoogle = isGoogle or false + + local speedFactor = 1.0 + if isGoogle then + speedFactor = speed + else + if speed ~= 0 then + speedFactor = math.abs(speed) * (maxRateRatio - 1) / 10 + 1 + end + if speed < 0 then + speedFactor = 1/speedFactor + end + end + + local wpm = math.ceil(100 * speedFactor) + local cps = math.floor((wpm * 5)/60) + + if type(length) == "string" then + length = string.len(length) + end + + return math.ceil(length/cps) +end + +function STTS.TextToSpeech(message,freqs,modulations, volume,name, coalition,point, speed,gender,culture,voice, googleTTS ) + if os == nil or io == nil then + env.info("[DCS-STTS] LUA modules os or io are sanitized. skipping. ") + return + end + + speed = speed or 1 + gender = gender or "female" + culture = culture or "" + voice = voice or "" + + + message = message:gsub("\"","\\\"") + + local cmd = string.format("start /min \"\" /d \"%s\" /b \"%s\" -f %s -m %s -c %s -p %s -n \"%s\" -h", STTS.DIRECTORY, STTS.EXECUTABLE, freqs, modulations, coalition,STTS.SRS_PORT, name ) + + if voice ~= "" then + cmd = cmd .. string.format(" -V \"%s\"",voice) + else + + if culture ~= "" then + cmd = cmd .. string.format(" -l %s",culture) + end + + if gender ~= "" then + cmd = cmd .. string.format(" -g %s",gender) + end + end + + if googleTTS == true then + cmd = cmd .. string.format(" -G \"%s\"",STTS.GOOGLE_CREDENTIALS) + end + + if speed ~= 1 then + cmd = cmd .. string.format(" -s %s",speed) + end + + if volume ~= 1.0 then + cmd = cmd .. string.format(" -v %s",volume) + end + + if point and type(point) == "table" and point.x then + local lat, lon, alt = coord.LOtoLL(point) + + lat = STTS.round(lat,4) + lon = STTS.round(lon,4) + alt = math.floor(alt) + + cmd = cmd .. string.format(" -L %s -O %s -A %s",lat,lon,alt) + end + + cmd = cmd ..string.format(" -t \"%s\"",message) + + if string.len(cmd) > 255 then + local filename = os.getenv('TMP') .. "\\DCS_STTS-" .. STTS.uuid() .. ".bat" + local script = io.open(filename,"w+") + script:write(cmd .. " && exit" ) + script:close() + cmd = string.format("\"%s\"",filename) + timer.scheduleFunction(os.remove, filename, timer.getTime() + 1) + end + + if string.len(cmd) > 255 then + env.info("[DCS-STTS] - cmd string too long") + env.info("[DCS-STTS] TextToSpeech Command :\n" .. cmd.."\n") + end + os.execute(cmd) + + return STTS.getSpeechTime(message,speed,googleTTS) + +end + +function STTS.PlayMP3(pathToMP3,freqs,modulations, volume,name, coalition,point ) + + local cmd = string.format("start \"\" /d \"%s\" /b /min \"%s\" -i \"%s\" -f %s -m %s -c %s -p %s -n \"%s\" -v %s -h", STTS.DIRECTORY, STTS.EXECUTABLE, pathToMP3, freqs, modulations, coalition,STTS.SRS_PORT, name, volume ) + + if point and type(point) == "table" and point.x then + local lat, lon, alt = coord.LOtoLL(point) + + lat = STTS.round(lat,4) + lon = STTS.round(lon,4) + alt = math.floor(alt) + + cmd = cmd .. string.format(" -L %s -O %s -A %s",lat,lon,alt) + end + + env.info("[DCS-STTS] MP3/OGG Command :\n" .. cmd.."\n") + os.execute(cmd) + +end diff --git a/README.md b/README.md index 3dba4e0..5c41c5e 100644 --- a/README.md +++ b/README.md @@ -740,6 +740,18 @@ the mission but there can be a delay of up to 30 seconds after activation for th You can also change the **name of a unit*** (unit, not group) to include "**hpriority**" to make it high priority for the JTAC, or "**priority**" to set it to be medium priority. JTAC's will prioritize targets within view by first marking hpriority targets, then priority targets, and finally all others. This works seemlessly with the all/vehicle/troop functionality as well. In this way you can have them lase SAMS, then AAA, then armor, or any other order you decide is preferable. +If the `DCS-SimpleTextToSpeech.lua` script is loaded, and configures (i.e. the `STTS.DIRECTORY`, `STTS.SRS_PORT` and optionaly the `STTS.GOOGLE_CREDENTIALS` variables are set), the JTAC can talk over SRS. + +To do this, you can specify the _radio parameter when calling ctld.JTACAutoLase like in this example : + +```lua +ctld.JTACAutoLase('JTAC1', 1688, true,"all", 4, { freq = "251.50", mod = "AM", name = "JTAC one" }) +``` +If you don't use the _radio parameter, CTLD will compute a FM frequency based on the laser designator code : 30Mhz + [second figure of the code] + [last two figures of the code] * 0.05. +For example, if the laser code is *1688*, the frequency will be *40.40Mhz*. + +JTAC frequency is available through the "JTAC Status" radio menu + # In Game ## Troop Loading and Unloading diff --git a/test-mission.miz b/test-mission.miz index 01b07f2..c14aa2e 100644 Binary files a/test-mission.miz and b/test-mission.miz differ