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