diff --git a/code/__HELPERS/mobs.dm b/code/__HELPERS/mobs.dm index c7b3b1cdf5de..a2f3c1ef8c18 100644 --- a/code/__HELPERS/mobs.dm +++ b/code/__HELPERS/mobs.dm @@ -251,6 +251,38 @@ GLOBAL_LIST_INIT(skin_tone_names, list( "mixed4" = "Macadamia", )) +GLOBAL_LIST_INIT(tts_voices_names, sortList(list( + "GB-alba" = "Alba (Scottish Female)", + "GB-aru" = "Aru (North-East English Female)", + "GB-jenny_dioco" = "Jenny (Welsh Female)", + "GB-northern_english_male" = "Josh (Yorkshire Male)", + "GB-southern_english_female" = "Lucy (London Female)", + "GB-vctk" = "Vctk (Midlands Female)", + "US-amy" = "Amy (Northern American Female)", + "US-danny" = "Danny (British American Male)", + "US-joe" = "Joe (Hawaiian Male)", + "US-kathleen" = "Kathleen (Elder Eastern American Female)", + "US-kusal" = "Kusal (Asian American Male)", + "US-libritts_r" = "Libritts (Michigan Female)" + ))) +GLOBAL_PROTECT(tts_voices_names) + +GLOBAL_LIST_INIT(tts_voices, sortList(list( + "GB-alba", + "GB-aru", + "GB-jenny_dioco", + "GB-northern_english_male", + "GB-southern_english_female", + "GB-vctk", + "US-amy", + "US-danny", + "US-joe", + "US-kathleen", + "US-kusal", + "US-libritts_r" + ))) +GLOBAL_PROTECT(tts_voices) + GLOBAL_LIST_EMPTY(species_list) /proc/age2agedescription(age) diff --git a/code/__HELPERS/piper.dm b/code/__HELPERS/piper.dm new file mode 100644 index 000000000000..50722e64a836 --- /dev/null +++ b/code/__HELPERS/piper.dm @@ -0,0 +1,71 @@ +#define UNTIL(X) while(!(X)) stoplag() // Used to be in unsorted.dm, but that is later than this file + +/** +* @param {String} message - The message to feed the model i.e. "Hello, world!" +* +* @param {String} model - The model i.e. "GB-alba" +* +* @param {number} pitch - Pitch multiplier, range (0.5-2.0) +* +* @returns {sound/} or FALSE +*/ +/proc/piper_tts(message, model, pitch, filters) + if(!CONFIG_GET(flag/tts_enable)) + return FALSE + + var/player_count = living_player_count() + if(!SSticker.tts_capped && player_count >= CONFIG_GET(number/tts_cap_shutoff)) + SSticker.tts_capped = TRUE + return FALSE + + if(SSticker.tts_capped) + if(player_count < CONFIG_GET(number/tts_uncap_reboot)) + SSticker.tts_capped = FALSE + else + return FALSE + + if(!SSticker.tts_alive) + return FALSE + + var/san_message = sanitize_tts_input(message) + var/san_model = sanitize_tts_input(model) + if(!pitch || !isnum(pitch)) + pitch = 1 + + var/string_filters = "" + if(filters && islist(filters)) + string_filters = jointext(assoc_to_keys(filters), "-") + var/file_name = "tmp/tts/[md5("[san_message][san_model][pitch][string_filters]")].wav" + + if(fexists(file_name)) + return sound(file_name) + + // TGS updates can clear out the tmp folder, so we need to create the folder again if it no longer exists. + if(!fexists("tmp/tts/init.txt")) + rustg_file_write("rustg HTTP requests can't write to folders that don't exist, so we need to make it exist.", "tmp/tts/init.txt") + + if(!filters || !islist(filters)) + filters = list() + + var/list/headers = list() + headers["Content-Type"] = "application/json" + headers["Authorization"] = CONFIG_GET(string/tts_http_token) + var/datum/http_request/request = new() + request.prepare(RUSTG_HTTP_METHOD_GET, "[CONFIG_GET(string/tts_http_url)]/tts?model=[url_encode(san_model)]&pitch=[url_encode(pitch)]", json_encode(list("message" = san_message, "filters" = filters)), headers, file_name) + + request.begin_async() + + UNTIL(request.is_complete()) + + var/datum/http_response/response = request.into_response() + if(response.errored || response.status_code > 299) + fdel(file_name) + return FALSE + + if(response.body == "bad auth" || response.body == "missing args") + fdel(file_name) + return FALSE + + var/sound/tts_sound = sound(file_name) + + return tts_sound diff --git a/code/__HELPERS/text.dm b/code/__HELPERS/text.dm index 99f3c0077adb..90bf6d386348 100644 --- a/code/__HELPERS/text.dm +++ b/code/__HELPERS/text.dm @@ -849,3 +849,9 @@ GLOBAL_LIST_INIT(binary, list("0","1")) /proc/sanitize_css_class_name(name) var/static/regex/regex = new(@"[^a-zA-Z0-9]","g") return replacetext(name, regex, "") + +/// Removes all unsafe cmd/shell text +/proc/sanitize_tts_input(txt) + var/and_replaced = replacetext(txt, "&", "and") // Manually sanitize "&" into "and" so it doesn't get consumed by the void + var/static/regex/regex = new(@"[^a-zA-Z0-9,._+:@%/\- ]","g") + return replacetext(and_replaced, regex, "") diff --git a/code/__HELPERS/tts_filters.dm b/code/__HELPERS/tts_filters.dm new file mode 100644 index 000000000000..cf42c2375915 --- /dev/null +++ b/code/__HELPERS/tts_filters.dm @@ -0,0 +1,116 @@ +// shamelessly copied from traits.dm +// filter accessor defines +#define ADD_FILTER(target, filter, source) \ + do { \ + var/list/_L; \ + if (!target.tts_filters) { \ + target.tts_filters = list(); \ + _L = target.tts_filters; \ + _L[filter] = list(source); \ + } else { \ + _L = target.tts_filters; \ + if (_L[filter]) { \ + _L[filter] |= list(source); \ + } else { \ + _L[filter] = list(source); \ + } \ + } \ + } while (0) +#define REMOVE_FILTER(target, filter, sources) \ + do { \ + var/list/_L = target.tts_filters; \ + var/list/_S; \ + if (sources && !islist(sources)) { \ + _S = list(sources); \ + } else { \ + _S = sources\ + }; \ + if (_L && _L[filter]) { \ + for (var/_T in _L[filter]) { \ + if ((!_S && (_T != ROUNDSTART_FILTER)) || (_T in _S)) { \ + _L[filter] -= _T \ + } \ + };\ + if (!length(_L[filter])) { \ + _L -= filter; \ + }; \ + if (!length(_L)) { \ + target.tts_filters = null \ + }; \ + } \ + } while (0) +#define REMOVE_FILTER_NOT_FROM(target, filter, sources) \ + do { \ + var/list/_filters_list = target.tts_filters; \ + var/list/_sources_list; \ + if (sources && !islist(sources)) { \ + _sources_list = list(sources); \ + } else { \ + _sources_list = sources\ + }; \ + if (_filters_list && _filters_list[filter]) { \ + for (var/_filter_source in _filters_list[filter]) { \ + if (!(_filter_source in _sources_list)) { \ + _filters_list[filter] -= _filter_source \ + } \ + };\ + if (!length(_filters_list[filter])) { \ + _filters_list -= filter; \ + }; \ + if (!length(_filters_list)) { \ + target.tts_filters = null \ + }; \ + } \ + } while (0) +#define REMOVE_FILTERS_NOT_IN(target, sources) \ + do { \ + var/list/_L = target.tts_filters; \ + var/list/_S = sources; \ + if (_L) { \ + for (var/_T in _L) { \ + _L[_T] &= _S;\ + if (!length(_L[_T])) { \ + _L -= _T; \ + }; \ + };\ + if (!length(_L)) { \ + target.tts_filters = null\ + };\ + }\ + } while (0) +#define REMOVE_FILTERS_IN(target, sources) \ + do { \ + var/list/_L = target.tts_filters; \ + var/list/_S = sources; \ + if (sources && !islist(sources)) { \ + _S = list(sources); \ + } else { \ + _S = sources\ + }; \ + if (_L) { \ + for (var/_T in _L) { \ + _L[_T] -= _S;\ + if (!length(_L[_T])) { \ + _L -= _T; \ + }; \ + };\ + if (!length(_L)) { \ + target.tts_filters = null\ + };\ + }\ + } while (0) +#define HAS_FILTER(target, filter) (target.tts_filters ? (target.tts_filters[filter] ? TRUE : FALSE) : FALSE) +#define HAS_FILTER_FROM(target, filter, source) (target.tts_filters ? (target.tts_filters[filter] ? (source in target.tts_filters[filter]) : FALSE) : FALSE) + +// common filter sources +#define ROUNDSTART_FILTER "roundstart" // quirks, mob types, cannot be removed +#define RADIO_PROCESSING_FILTER "radio_processing" + +// tts filters +#define TTS_FILTER_LIZARD "lizard" +#define TTS_FILTER_ALIEN "alien" +#define TTS_FILTER_ETHEREAL "ethereal" +#define TTS_FILTER_ROBOTIC "robotic" +#define TTS_FILTER_MASKED "masked" +#define TTS_FILTER_ROBOCOP "robocop" +#define TTS_FILTER_RADIO "radio" diff --git a/code/__HELPERS/unsorted.dm b/code/__HELPERS/unsorted.dm index 74ec550f4a37..399b34f51d0f 100644 --- a/code/__HELPERS/unsorted.dm +++ b/code/__HELPERS/unsorted.dm @@ -1257,8 +1257,6 @@ GLOBAL_DATUM_INIT(dview_mob, /mob/dview, new) return FALSE return TRUE -#define UNTIL(X) while(!(X)) stoplag() - /proc/pass(...) return diff --git a/code/controllers/configuration/entries/game_options.dm b/code/controllers/configuration/entries/game_options.dm index 7e17893af9b9..a67c14d4474a 100644 --- a/code/controllers/configuration/entries/game_options.dm +++ b/code/controllers/configuration/entries/game_options.dm @@ -444,3 +444,19 @@ /datum/config_entry/number/max_shuttle_size config_entry_value = 250 + +/datum/config_entry/string/tts_http_url + protection = CONFIG_ENTRY_LOCKED + +/datum/config_entry/string/tts_http_token + protection = CONFIG_ENTRY_LOCKED|CONFIG_ENTRY_HIDDEN + +/datum/config_entry/flag/tts_enable + +/datum/config_entry/number/tts_cap_shutoff + config_entry_value = 75 + protection = CONFIG_ENTRY_LOCKED + +/datum/config_entry/number/tts_uncap_reboot + config_entry_value = 60 + protection = CONFIG_ENTRY_LOCKED diff --git a/code/controllers/subsystem/ticker.dm b/code/controllers/subsystem/ticker.dm index 1185be13bc9b..a3eeab3ae433 100755 --- a/code/controllers/subsystem/ticker.dm +++ b/code/controllers/subsystem/ticker.dm @@ -60,6 +60,10 @@ SUBSYSTEM_DEF(ticker) var/music_available = 0 + var/pinging_tts = FALSE + var/tts_alive = FALSE + var/tts_capped = FALSE + /datum/controller/subsystem/ticker/Initialize(timeofday) load_mode() @@ -154,6 +158,9 @@ SUBSYSTEM_DEF(ticker) for(var/obj/machinery/cryopod/pod as anything in GLOB.cryopods) pod.PowerOff() + if(CONFIG_GET(flag/tts_enable) && !pinging_tts) + INVOKE_ASYNC(src, PROC_REF(ping_tts)) + switch(current_state) if(GAME_STATE_STARTUP) if(Master.initializations_finished_with_no_players_logged_in) @@ -768,3 +775,19 @@ SUBSYSTEM_DEF(ticker) /datum/controller/subsystem/ticker/Shutdown() gather_newscaster() //called here so we ensure the log is created even upon admin reboot + +/// Ping TTS API - If we don't get a response, shut down TTS +/datum/controller/subsystem/ticker/proc/ping_tts() + pinging_tts = TRUE + + var/datum/http_request/request = new() + request.prepare(RUSTG_HTTP_METHOD_GET, "[CONFIG_GET(string/tts_http_url)]/ping") + request.begin_async() + UNTIL(request.is_complete()) + var/datum/http_response/response = request.into_response() + if(response.errored || response.status_code > 299) + tts_alive = FALSE + else + tts_alive = TRUE + + pinging_tts = FALSE diff --git a/code/game/atoms_movable.dm b/code/game/atoms_movable.dm index e1f80cde837e..383ec8f2cee6 100644 --- a/code/game/atoms_movable.dm +++ b/code/game/atoms_movable.dm @@ -60,6 +60,9 @@ ///Highest-intensity light affecting us, which determines our visibility. var/affecting_dynamic_lumi = 0 + var/tts_voice + var/tts_pitch = 1 + var/list/tts_filters /atom/movable/Initialize(mapload, ...) . = ..() @@ -95,7 +98,7 @@ CanAtmosPass = ATMOS_PASS_YES air_update_turf(TRUE) loc.handle_atom_del(src) - + if(opacity) RemoveElement(/datum/element/light_blocking) diff --git a/code/game/machinery/_machinery.dm b/code/game/machinery/_machinery.dm index 050e4f840d37..5c173c74fb9c 100644 --- a/code/game/machinery/_machinery.dm +++ b/code/game/machinery/_machinery.dm @@ -143,6 +143,8 @@ Class Procs: . = ..() GLOB.machines += src + ADD_FILTER(src, TTS_FILTER_ROBOTIC, ROUNDSTART_FILTER) + if(ispath(circuit, /obj/item/circuitboard)) circuit = new circuit circuit.apply_default_parts(src) @@ -387,7 +389,7 @@ Class Procs: if((interaction_flags_machine & INTERACT_MACHINE_WIRES_IF_OPEN) && panel_open && (attempt_wire_interaction(user) == WIRE_INTERACTION_BLOCK)) return TRUE if((user.mind?.has_martialart(MARTIALART_BUSTERSTYLE)) && (user.a_intent == INTENT_GRAB)) //buster arm shit since it can throw vendors - return + return return ..() /obj/machinery/tool_act(mob/living/user, obj/item/tool, tool_type, is_right_clicking) @@ -500,7 +502,7 @@ Class Procs: /obj/proc/default_unfasten_wrench(mob/user, obj/item/wrench, time = 20) //try to unwrench an object in a WONDERFUL DYNAMIC WAY if((flags_1 & NODECONSTRUCT_1) || wrench.tool_behaviour != TOOL_WRENCH) return CANT_UNFASTEN - + var/turf/ground = get_turf(src) if(!anchored && ground.is_blocked_turf(exclude_mobs = TRUE, source_atom = src)) to_chat(user, span_notice("You fail to secure [src].")) @@ -547,7 +549,7 @@ Class Procs: for(var/obj/item/B in W.contents) if(istype(B, P) && istype(A, P)) //won't replace beakers if they have reagents in them to prevent funny explosions - if(istype(B,/obj/item/reagent_containers) && length(B.reagents?.reagent_list)) + if(istype(B,/obj/item/reagent_containers) && length(B.reagents?.reagent_list)) continue // If it's a corrupt or rigged cell, attempting to send it through Bluespace could have unforeseen consequences. if(istype(B, /obj/item/stock_parts/cell) && W.works_from_distance) diff --git a/code/game/machinery/telecomms/broadcasting.dm b/code/game/machinery/telecomms/broadcasting.dm index 7c320f600d01..20d46c989345 100644 --- a/code/game/machinery/telecomms/broadcasting.dm +++ b/code/game/machinery/telecomms/broadcasting.dm @@ -187,6 +187,17 @@ if(M.client && (M.client.prefs.chat_toggles & CHAT_GHOSTRADIO)) receive |= M + // TTS generation + var/model = pick(GLOB.tts_voices) + if(GLOB.tts_voices.Find(virt.virt_tts_voice)) // Sanitize with an immutable list + model = virt.virt_tts_voice + + var/pitch = rand(0.8, 1.2) + if(virt.virt_tts_pitch) + pitch = virt.virt_tts_pitch + + var/tts_sound = piper_tts(html_decode(message), model, pitch, virt.virt_tts_filters) + // Render the message and have everybody hear it. // Always call this on the virtualspeaker to avoid issues. var/spans = data["spans"] @@ -194,6 +205,10 @@ var/rendered = virt.compose_message(virt, language, message, frequency, spans) for(var/atom/movable/hearer in receive) hearer.Hear(rendered, virt, language, message, frequency, spans, message_mods) + if(ismob(hearer)) + var/mob/hearing_mob = hearer + if(tts_sound && hearing_mob.client?.prefs?.read_preference(/datum/preference/toggle/tts_hear_radio) && hearing_mob.has_language(language)) + hearing_mob.playsound_local(vol = spans[SPAN_COMMAND] ? 45 : 30, S = tts_sound) // TTS play // This following recording is intended for research and feedback in the use of department radio channels if(length(receive)) diff --git a/code/game/say.dm b/code/game/say.dm index 45410e3f1656..e4f8a0c8c2bb 100644 --- a/code/game/say.dm +++ b/code/game/say.dm @@ -39,9 +39,20 @@ GLOBAL_LIST_INIT(freqtospan, list( /atom/movable/proc/send_speech(message, range = 7, obj/source = src, bubble_type, list/spans, datum/language/message_language = null, list/message_mods = list()) var/rendered = compose_message(src, message_language, message, , spans, message_mods) + + if(!GLOB.tts_voices.Find(tts_voice)) // Sanitize with an immutable list + tts_voice = pick(GLOB.tts_voices) + tts_pitch = rand(8, 12) * 0.1 + + var/tts_sound = piper_tts(message, tts_voice, tts_pitch, tts_filters) + for(var/_AM in get_hearers_in_view(range, source)) var/atom/movable/AM = _AM AM.Hear(rendered, src, message_language, message, , spans, message_mods) + if(ismob(AM)) + var/mob/hearing_mob = AM + if(tts_sound && hearing_mob.client?.prefs?.read_preference(/datum/preference/toggle/tts_hear) && hearing_mob.has_language(message_language)) + hearing_mob.playsound_local(get_turf(src), vol = 100, S = tts_sound) // TTS play /atom/movable/proc/compose_message(atom/movable/speaker, datum/language/message_language, raw_message, radio_freq, list/spans, list/message_mods = list(), face_name = FALSE) //This proc uses text() because it is faster than appending strings. Thanks BYOND. @@ -171,6 +182,9 @@ GLOBAL_LIST_INIT(freqtospan, list( var/realvoice // Yogs -- new UUID, basically, I guess var/atom/movable/source var/obj/item/radio/radio + var/virt_tts_voice + var/virt_tts_pitch + var/list/virt_tts_filters INITIALIZE_IMMEDIATE(/atom/movable/virtualspeaker) /atom/movable/virtualspeaker/Initialize(mapload, atom/movable/M, radio) @@ -184,6 +198,13 @@ INITIALIZE_IMMEDIATE(/atom/movable/virtualspeaker) verb_ask = M.verb_ask verb_exclaim = M.verb_exclaim verb_yell = M.verb_yell + virt_tts_voice = M.tts_voice + virt_tts_pitch = M.tts_pitch + virt_tts_filters = M.tts_filters + + LAZYINITLIST(virt_tts_filters) + if(!virt_tts_filters[TTS_FILTER_RADIO]) + virt_tts_filters[TTS_FILTER_RADIO] = list(RADIO_PROCESSING_FILTER) // The mob's job identity if(ishuman(M)) diff --git a/code/modules/antagonists/changeling/changeling.dm b/code/modules/antagonists/changeling/changeling.dm index 22df726a912f..199ba2a9dec7 100644 --- a/code/modules/antagonists/changeling/changeling.dm +++ b/code/modules/antagonists/changeling/changeling.dm @@ -161,10 +161,10 @@ for(var/datum/action/changeling/p in purchasedpowers) if(p.dna_cost > 0) additionalpoints += p.dna_cost - + purchasedpowers -= p p.Remove(owner.current) - + geneticpoints = additionalpoints /datum/antagonist/changeling/proc/reset_powers() @@ -226,7 +226,7 @@ to_chat(owner.current, "We lack the energy to evolve new abilities right now.") return //this checks for conflicting abilities that you dont want players to have at the same time (movement speed abilities for example) - for(var/conflictingpower in thepower.conflicts) + for(var/conflictingpower in thepower.conflicts) if(has_sting(conflictingpower)) to_chat(owner.current, "This power conflicts with another power we currently have!") return @@ -324,6 +324,7 @@ prof.underwear = H.underwear prof.undershirt = H.undershirt prof.socks = H.socks + prof.tts_voice = H.tts_voice if(H.mind)//yes we need to check this prof.accent = H.mind.accent_name @@ -591,7 +592,7 @@ // however, this is "close enough" preliminary checks to not block click if(!isliving(clicked) || !IN_GIVEN_RANGE(ling, clicked, sting_range)) return - + chosen_sting.try_to_sting(ling, clicked) ling.next_click = world.time + 5 @@ -634,6 +635,7 @@ var/underwear var/undershirt var/socks + var/tts_voice var/accent = null /// What scars the target had when we copied them, in string form (like persistent scars) var/list/stored_scars @@ -657,6 +659,7 @@ newprofile.underwear = underwear newprofile.undershirt = undershirt newprofile.socks = socks + newprofile.tts_voice = tts_voice newprofile.accent = accent newprofile.stored_scars = stored_scars.Copy() diff --git a/code/modules/client/preferences/tts_hear.dm b/code/modules/client/preferences/tts_hear.dm new file mode 100644 index 000000000000..ffa2e65d76b5 --- /dev/null +++ b/code/modules/client/preferences/tts_hear.dm @@ -0,0 +1,5 @@ +/// Enables hearing TTS or not +/datum/preference/toggle/tts_hear + category = PREFERENCE_CATEGORY_GAME_PREFERENCES + savefile_key = "tts_hear" + savefile_identifier = PREFERENCE_PLAYER diff --git a/code/modules/client/preferences/tts_hear_radio.dm b/code/modules/client/preferences/tts_hear_radio.dm new file mode 100644 index 000000000000..36007896572b --- /dev/null +++ b/code/modules/client/preferences/tts_hear_radio.dm @@ -0,0 +1,5 @@ +/// Enables hearing TTS on the radio or not +/datum/preference/toggle/tts_hear_radio + category = PREFERENCE_CATEGORY_GAME_PREFERENCES + savefile_key = "tts_hear_radio" + savefile_identifier = PREFERENCE_PLAYER diff --git a/code/modules/client/preferences/tts_pitch.dm b/code/modules/client/preferences/tts_pitch.dm new file mode 100644 index 000000000000..b88953158912 --- /dev/null +++ b/code/modules/client/preferences/tts_pitch.dm @@ -0,0 +1,11 @@ +/datum/preference/numeric/tts_pitch + category = PREFERENCE_CATEGORY_SECONDARY_FEATURES + savefile_identifier = PREFERENCE_CHARACTER + savefile_key = "tts_pitch" + + minimum = 0.8 + maximum = 1.2 + step = 0.05 + +/datum/preference/numeric/tts_pitch/apply_to_human(mob/living/carbon/human/target, value) + target.tts_pitch = value diff --git a/code/modules/client/preferences/tts_voice.dm b/code/modules/client/preferences/tts_voice.dm new file mode 100644 index 000000000000..2eae09832af9 --- /dev/null +++ b/code/modules/client/preferences/tts_voice.dm @@ -0,0 +1,17 @@ +/datum/preference/choiced/tts_voice + category = PREFERENCE_CATEGORY_SECONDARY_FEATURES + savefile_identifier = PREFERENCE_CHARACTER + savefile_key = "tts_voice" + +/datum/preference/choiced/tts_voice/init_possible_values() + return GLOB.tts_voices + +/datum/preference/choiced/tts_voice/compile_constant_data() + var/list/data = ..() + + data[CHOICED_PREFERENCE_DISPLAY_NAMES] = GLOB.tts_voices_names + + return data + +/datum/preference/choiced/tts_voice/apply_to_human(mob/living/carbon/human/target, value) + target.tts_voice = value diff --git a/code/modules/clothing/masks/_masks.dm b/code/modules/clothing/masks/_masks.dm index 105ca46b8ac4..3b3990ce1474 100644 --- a/code/modules/clothing/masks/_masks.dm +++ b/code/modules/clothing/masks/_masks.dm @@ -10,6 +10,7 @@ var/adjusted_flags = null var/mutantrace_variation = NO_MUTANTRACE_VARIATION //Are there special sprites for specific situations? Don't use this unless you need to. var/mutantrace_adjusted = NO_MUTANTRACE_VARIATION //Are there special sprites for specific situations? Don't use this unless you need to. + var/list/mask_tts_filters /obj/item/clothing/mask/attack_self(mob/user) if(CHECK_BITFIELD(clothing_flags, VOICEBOX_TOGGLABLE)) @@ -24,10 +25,16 @@ else UnregisterSignal(M, COMSIG_MOB_SAY) + for(var/filter in mask_tts_filters) + ADD_FILTER(M, filter, "[REF(src)]") + /obj/item/clothing/mask/dropped(mob/M) . = ..() UnregisterSignal(M, COMSIG_MOB_SAY) + for(var/filter in mask_tts_filters) + REMOVE_FILTER(M, filter, "[REF(src)]") + /obj/item/clothing/mask/proc/handle_speech() /obj/item/clothing/mask/worn_overlays(isinhands = FALSE) diff --git a/code/modules/clothing/masks/gasmask.dm b/code/modules/clothing/masks/gasmask.dm index ab7c14bf4198..ef48b5f9e4b1 100644 --- a/code/modules/clothing/masks/gasmask.dm +++ b/code/modules/clothing/masks/gasmask.dm @@ -11,6 +11,7 @@ resistance_flags = FIRE_PROOF mutantrace_variation = MUTANTRACE_VARIATION armor = list(MELEE = 0, BULLET = 0, LASER = 0, ENERGY = 0, BOMB = 0, BIO = 60, RAD = 0, FIRE = 0, ACID = 0) + mask_tts_filters = list(TTS_FILTER_MASKED) // **** Atmos gas mask **** @@ -73,6 +74,7 @@ mutantrace_variation = NO_MUTANTRACE_VARIATION actions_types = list(/datum/action/item_action/adjust) dog_fashion = /datum/dog_fashion/head/clown + mask_tts_filters = null // performer masks expect to be talked through var/list/clownmask_designs = list() /obj/item/clothing/mask/gas/clown_hat/Initialize(mapload) diff --git a/code/modules/mob/living/carbon/human/species_types/ethereal.dm b/code/modules/mob/living/carbon/human/species_types/ethereal.dm index 341a4ca9ab97..8c5b7fb3aa27 100644 --- a/code/modules/mob/living/carbon/human/species_types/ethereal.dm +++ b/code/modules/mob/living/carbon/human/species_types/ethereal.dm @@ -8,6 +8,7 @@ mutantlungs = /obj/item/organ/lungs/ethereal mutantstomach = /obj/item/organ/stomach/cell/ethereal mutantheart = /obj/item/organ/heart/ethereal + mutanttongue = /obj/item/organ/tongue/ethereal exotic_blood = /datum/reagent/consumable/liquidelectricity //Liquid Electricity. fuck you think of something better gamer siemens_coeff = 0.5 //They thrive on energy brutemod = 1.25 //Don't rupture their membranes diff --git a/code/modules/mob/living/say.dm b/code/modules/mob/living/say.dm index 0aa25a9049cc..0ddd07b4a4c7 100644 --- a/code/modules/mob/living/say.dm +++ b/code/modules/mob/living/say.dm @@ -236,7 +236,7 @@ GLOBAL_LIST_INIT(special_radio_keys, list( send_speech(message, message_range, src, bubble_type, spans, language, message_mods) - + return on_say_success(message,message_range,succumbed, spans, language, message_mods)//Yogs /mob/living/proc/on_say_success(message,message_range,succumbed, spans, language, message_mods) // A helper function of stuff that is deferred to when /mob/living/say() is done and has successfully said something. @@ -305,13 +305,35 @@ GLOBAL_LIST_INIT(special_radio_keys, list( eavesdropping = stars(message) eavesrendered = compose_message(src, message_language, eavesdropping, , spans, message_mods) + // TTS generation + if(!GLOB.tts_voices.Find(tts_voice)) // Sanitize with an immutable list + tts_voice = pick(GLOB.tts_voices) + + if(!tts_pitch || !isnum(tts_pitch)) + tts_pitch = rand(8, 12) * 0.1 + + var/tts_sound = piper_tts(html_decode(message), tts_voice, tts_pitch, tts_filters) + var/rendered = compose_message(src, message_language, message, , spans, message_mods) + var/message_volume = 100 + if(message_mods[MODE_HEADSET]) + message_volume = 0 + else if (message_mods[WHISPER_MODE]) + message_volume = 40 for(var/_AM in listening) var/atom/movable/AM = _AM if(eavesdrop_range && get_dist(source, AM) > message_range && !(the_dead[AM])) AM.Hear(eavesrendered, src, message_language, eavesdropping, , spans, message_mods) + if(ismob(AM)) + var/mob/hearing_mob = AM + if(tts_sound && hearing_mob.client?.prefs?.read_preference(/datum/preference/toggle/tts_hear) && hearing_mob.has_language(message_language)) + hearing_mob.playsound_local(get_turf(src), vol = 5, S = tts_sound) // TTS play else AM.Hear(rendered, src, message_language, message, , spans, message_mods) + if(ismob(AM)) + var/mob/hearing_mob = AM + if(tts_sound && hearing_mob.client?.prefs?.read_preference(/datum/preference/toggle/tts_hear) && hearing_mob.has_language(message_language)) + hearing_mob.playsound_local(get_turf(src), vol = message_volume, S = tts_sound) // TTS play SEND_GLOBAL_SIGNAL(COMSIG_GLOB_LIVING_SAY_SPECIAL, src, message) //speech bubble @@ -388,7 +410,7 @@ GLOBAL_LIST_INIT(special_radio_keys, list( if(message_mods[RADIO_EXTENSION] == MODE_DEPARTMENT || (message_mods[RADIO_EXTENSION] in I.channels)) I.talk_into(src, message, message_mods[RADIO_EXTENSION], spans, language, message_mods) return ITALICS | REDUCE_RANGE - + switch(message_mods[RADIO_EXTENSION]) if(MODE_R_HAND) for(var/obj/item/r_hand in get_held_items_for_side("r", all = TRUE)) diff --git a/code/modules/mob/living/silicon/silicon.dm b/code/modules/mob/living/silicon/silicon.dm index e8ef8b4249a7..71f361674553 100644 --- a/code/modules/mob/living/silicon/silicon.dm +++ b/code/modules/mob/living/silicon/silicon.dm @@ -56,6 +56,7 @@ /mob/living/silicon/Initialize(mapload) . = ..() + ADD_FILTER(src, TTS_FILTER_ROBOTIC, ROUNDSTART_FILTER) GLOB.silicon_mobs += src faction += "silicon" for(var/datum/atom_hud/data/diagnostic/diag_hud in GLOB.huds) diff --git a/code/modules/mob/living/simple_animal/bot/bot.dm b/code/modules/mob/living/simple_animal/bot/bot.dm index a6c8a8f93f74..128d588d735a 100644 --- a/code/modules/mob/living/simple_animal/bot/bot.dm +++ b/code/modules/mob/living/simple_animal/bot/bot.dm @@ -143,6 +143,7 @@ /mob/living/simple_animal/bot/Initialize(mapload) . = ..() + ADD_FILTER(src, TTS_FILTER_ROBOTIC, ROUNDSTART_FILTER) GLOB.bots_list += src access_card = new /obj/item/card/id(src) //This access is so bots can be immediately set to patrol and leave Robotics, instead of having to be let out first. diff --git a/code/modules/surgery/organs/organ_internal.dm b/code/modules/surgery/organs/organ_internal.dm index 0a356759507b..4cdee909519b 100644 --- a/code/modules/surgery/organs/organ_internal.dm +++ b/code/modules/surgery/organs/organ_internal.dm @@ -35,6 +35,8 @@ ///Do we effect the appearance of our mob. Used to save time in preference code var/visual = TRUE + var/list/organ_tts_filters + /obj/item/organ/proc/Insert(mob/living/carbon/M, special = 0, drop_if_replaced = TRUE,special_zone = null) if(!iscarbon(M) || owner == M) return @@ -58,6 +60,9 @@ var/datum/action/A = X A.Grant(M) + for(var/filter in organ_tts_filters) + ADD_FILTER(M, filter, "[REF(src)]") + //Special is for instant replacement like autosurgeons /obj/item/organ/proc/Remove(mob/living/carbon/M, special = FALSE) owner = null @@ -71,6 +76,8 @@ var/datum/action/A = X A.Remove(M) + for(var/filter in organ_tts_filters) + REMOVE_FILTER(M, filter, "[REF(src)]") /obj/item/organ/proc/on_find(mob/living/finder) return diff --git a/code/modules/surgery/organs/tongue.dm b/code/modules/surgery/organs/tongue.dm index ac2dced2b692..b4f9b80ac744 100644 --- a/code/modules/surgery/organs/tongue.dm +++ b/code/modules/surgery/organs/tongue.dm @@ -47,7 +47,7 @@ /obj/item/organ/tongue/proc/handle_speech(datum/source, list/speech_args) if(honked) // you have a bike horn inside of your tongue. Time to honk playsound(source, honkednoise, 50, TRUE) - say_mod = "honks" // overrides original tongue here + say_mod = "honks" // overrides original tongue here /obj/item/organ/tongue/Insert(mob/living/carbon/M, special = 0) ..() @@ -89,6 +89,7 @@ say_mod = "hisses" taste_sensitivity = 10 // combined nose + tongue, extra sensitive modifies_speech = TRUE + organ_tts_filters = list(TTS_FILTER_LIZARD) /obj/item/organ/tongue/lizard/handle_speech(datum/source, list/speech_args) ..() @@ -204,6 +205,7 @@ say_mod = "hisses" taste_sensitivity = 10 // LIZARDS ARE ALIENS CONFIRMED modifies_speech = TRUE // not really, they just hiss + organ_tts_filters = list(TTS_FILTER_ALIEN) var/static/list/languages_possible_alien = typecacheof(list( /datum/language/xenocommon, /datum/language/common, @@ -251,6 +253,12 @@ icon_state = "tongueplasma" modifies_speech = FALSE +/obj/item/organ/tongue/preternis + name = "augmented tongue" + desc = "A tongue made of metal. Makes the user slightly more difficult to understand." + modifies_speech = FALSE + organ_tts_filters = list(TTS_FILTER_ROBOTIC) + /obj/item/organ/tongue/robot name = "robotic voicebox" desc = "A voice synthesizer that can interface with organic lifeforms." @@ -262,10 +270,11 @@ attack_verb = list("beeped", "booped") modifies_speech = TRUE taste_sensitivity = NO_TASTE_SENSITIVITY // not as good as an organic tongue + organ_tts_filters = list(TTS_FILTER_ROBOTIC) /obj/item/organ/tongue/robot/emp_act(severity) if(prob(5)) - return + return owner.apply_effect(EFFECT_STUTTER, rand(5 SECONDS, 2 MINUTES)) owner.emote("scream") to_chat(owner, "Alert: Vocal cords are malfunctioning.") @@ -332,3 +341,11 @@ /obj/item/organ/tongue/slime/Initialize(mapload) . = ..() languages_possible |= languages_possible_jelly + +/obj/item/organ/tongue/ethereal + name = "electric discharger" + desc = "A sophisticated ethereal organ, capable of synthesising speech via electrical discharge." + say_mod = "crackles" + taste_sensitivity = 10 // combined nose + tongue, extra sensitive + modifies_speech = TRUE + organ_tts_filters = list(TTS_FILTER_ETHEREAL) diff --git a/config/config.txt b/config/config.txt index e077fe2da1f1..9bc895f9a8a1 100644 --- a/config/config.txt +++ b/config/config.txt @@ -453,3 +453,18 @@ MAX_SHUTTLE_SIZE 300 ## Comment to disable sending a toast notification on the host server when initializations complete. ## Even if this is enabled, a notification will only be sent if there are no clients connected. TOAST_NOTIFICATION_ON_INIT + +## Enable/disable Text-to-Speech +TTS_ENABLE + +## Link to a HTTP server that's been set up on a server. +TTS_HTTP_URL http://localhost:8133 + +## Token that can be used to prevent misuse of your TTS server that you've set up. +TTS_HTTP_TOKEN mysecuretoken + +## At this many living players, TTS will automatically shut off +TTS_CAP_SHUTOFF 75 + +## When TTS is shut off, if the amount of living players goes below this, TTS will turn back on +TTS_UNCAP_REBOOT 60 diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/tts_pitch.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/tts_pitch.tsx new file mode 100644 index 000000000000..81d73d6bb317 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/tts_pitch.tsx @@ -0,0 +1,6 @@ +import { Feature, FeatureNumberInput } from "../base"; + +export const tts_pitch: Feature = { + name: "Text-to-Speech Pitch", + component: FeatureNumberInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/tts_voice.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/tts_voice.tsx new file mode 100644 index 000000000000..f60d0ca0e733 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/tts_voice.tsx @@ -0,0 +1,6 @@ +import { FeatureDropdownInput, FeatureChoiced } from "../base"; + +export const tts_voice: FeatureChoiced = { + name: "Text-to-Speech Voice", + component: FeatureDropdownInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/tts_hear.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/tts_hear.tsx new file mode 100644 index 000000000000..03e603063ec2 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/tts_hear.tsx @@ -0,0 +1,8 @@ +import { CheckboxInput, FeatureToggle } from "../base"; + +export const tts_hear: FeatureToggle = { + name: "Hear TTS", + category: "GAMEPLAY", + description: "If turned off, Text-to-Speech will be muted for you.", + component: CheckboxInput, +}; diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/tts_hear_radio.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/tts_hear_radio.tsx new file mode 100644 index 000000000000..9684457708c7 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/tts_hear_radio.tsx @@ -0,0 +1,8 @@ +import { CheckboxInput, FeatureToggle } from "../base"; + +export const tts_hear_radio: FeatureToggle = { + name: "Hear radio TTS", + category: "GAMEPLAY", + description: "If turned off, Text-to-Speech on radio channels will be muted for you.", + component: CheckboxInput, +}; diff --git a/yogstation.dme b/yogstation.dme index 23b33a2472d9..f6780ee9dd0e 100644 --- a/yogstation.dme +++ b/yogstation.dme @@ -226,6 +226,7 @@ #include "code\__HELPERS\mouse_control.dm" #include "code\__HELPERS\nameof.dm" #include "code\__HELPERS\names.dm" +#include "code\__HELPERS\piper.dm" #include "code\__HELPERS\priority_announce.dm" #include "code\__HELPERS\pronouns.dm" #include "code\__HELPERS\radiation.dm" @@ -240,6 +241,7 @@ #include "code\__HELPERS\text.dm" #include "code\__HELPERS\time.dm" #include "code\__HELPERS\traits.dm" +#include "code\__HELPERS\tts_filters.dm" #include "code\__HELPERS\type2type.dm" #include "code\__HELPERS\typelists.dm" #include "code\__HELPERS\unsorted.dm" @@ -2140,6 +2142,10 @@ #include "code\modules\client\preferences\species.dm" #include "code\modules\client\preferences\tgui.dm" #include "code\modules\client\preferences\tooltips.dm" +#include "code\modules\client\preferences\tts_hear.dm" +#include "code\modules\client\preferences\tts_hear_radio.dm" +#include "code\modules\client\preferences\tts_pitch.dm" +#include "code\modules\client\preferences\tts_voice.dm" #include "code\modules\client\preferences\ui_style.dm" #include "code\modules\client\preferences\uplink_location.dm" #include "code\modules\client\preferences\widescreen.dm" diff --git a/yogstation/code/controllers/subsystem/yogs.dm b/yogstation/code/controllers/subsystem/yogs.dm index 146134a45a35..498924e04d1f 100644 --- a/yogstation/code/controllers/subsystem/yogs.dm +++ b/yogstation/code/controllers/subsystem/yogs.dm @@ -141,6 +141,16 @@ SUBSYSTEM_DEF(Yogs) for(var/path in subtypesof(/datum/corporation)) new path + // Clean up TTS files from last round + for(var/filename in flist("tmp/tts/")) + fdel("tmp/tts/[filename]") + + var/list/headers = list() + headers["Authorization"] = CONFIG_GET(string/tts_http_token) + var/datum/http_request/request = new() + request.prepare(RUSTG_HTTP_METHOD_POST, "[CONFIG_GET(string/tts_http_url)]/tts_clear_cache", , headers) + request.begin_async() + return SS_INIT_SUCCESS /datum/controller/subsystem/Yogs/fire(resumed = 0) diff --git a/yogstation/code/modules/clothing/masks/hailer.dm b/yogstation/code/modules/clothing/masks/hailer.dm index 04a411f2fe14..d45c53ef5d21 100644 --- a/yogstation/code/modules/clothing/masks/hailer.dm +++ b/yogstation/code/modules/clothing/masks/hailer.dm @@ -5,15 +5,16 @@ button_icon = 'yogstation/icons/mob/actions/actions.dmi' /obj/item/clothing/mask/gas/sechailer + mask_tts_filters = list(TTS_FILTER_ROBOCOP) var/obj/item/radio/radio //For engineering alerts. var/radio_key = /obj/item/encryptionkey/headset_medsec //needs med to in order to request medical help for one of the things var/dispatch_cooldown = 25 SECONDS var/last_dispatch = 0 var/list/options = list( - "code 601 (Murder) in progress" = RADIO_CHANNEL_SECURITY, - "code 101 (Resisting Arrest) in progress" = RADIO_CHANNEL_SECURITY, - "code 309 (Breaking and entering) in progress" = RADIO_CHANNEL_SECURITY, - "code 306 (Riot) in progress" = RADIO_CHANNEL_SECURITY, + "code 601 (Murder) in progress" = RADIO_CHANNEL_SECURITY, + "code 101 (Resisting Arrest) in progress" = RADIO_CHANNEL_SECURITY, + "code 309 (Breaking and entering) in progress" = RADIO_CHANNEL_SECURITY, + "code 306 (Riot) in progress" = RADIO_CHANNEL_SECURITY, "code 401 (Assault, Officer) in progress" = RADIO_CHANNEL_SECURITY, "reporting an injured civilian" = RADIO_CHANNEL_MEDICAL ) diff --git a/yogstation/code/modules/mob/living/carbon/human/species_types/preternis/preternis.dm b/yogstation/code/modules/mob/living/carbon/human/species_types/preternis/preternis.dm index 2b4e7e97f1c1..0ea3c38922a6 100644 --- a/yogstation/code/modules/mob/living/carbon/human/species_types/preternis/preternis.dm +++ b/yogstation/code/modules/mob/living/carbon/human/species_types/preternis/preternis.dm @@ -31,6 +31,7 @@ mutanteyes = /obj/item/organ/eyes/robotic/preternis mutantlungs = /obj/item/organ/lungs/preternis mutantstomach = /obj/item/organ/stomach/cell/preternis + mutanttongue = /obj/item/organ/tongue/preternis yogs_virus_infect_chance = 25 virus_resistance_boost = 10 //YEOUTCH,good luck getting it out virus_stage_rate_boost = 5 //Not designed with viruses in mind since it doesn't usually get in @@ -109,7 +110,7 @@ /datum/action/innate/maglock/Grant(mob/M) if(!ispreternis(M)) return - var/mob/living/carbon/human/H = M + var/mob/living/carbon/human/H = M owner_species = H.dna.species . = ..() @@ -152,7 +153,7 @@ H.throw_alert("preternis_emag", /atom/movable/screen/alert/high/preternis) to_chat(H,span_danger("ALERT! OPTIC SENSORS FAILURE.VISION PROCESSOR COMPROMISED.")) return TRUE - + /datum/species/preternis/handle_chemicals(datum/reagent/chem, mob/living/carbon/human/H) . = ..() if(H.reagents.has_reagent(/datum/reagent/teslium)) @@ -197,7 +198,7 @@ H.add_movespeed_modifier("preternis_magboot", update=TRUE, priority=100, multiplicative_slowdown=1, blacklisted_movetypes=(FLYING|FLOATING)) else if(H.has_movespeed_modifier("preternis_magboot")) H.remove_movespeed_modifier("preternis_magboot") - + /datum/species/preternis/spec_life(mob/living/carbon/human/H) . = ..() if(tesliumtrip && !H.reagents.has_reagent(/datum/reagent/teslium))//remove teslium effects if you don't have it in you