diff --git a/code/__DEFINES/say.dm b/code/__DEFINES/say.dm index 456467a56761..8697329ccdee 100644 --- a/code/__DEFINES/say.dm +++ b/code/__DEFINES/say.dm @@ -81,6 +81,7 @@ #define SPAN_SINGING "singing" #define SPAN_CULTLARGE "cultlarge" #define SPAN_HELIUM "small" +#define SPAN_COLOSSUS "colossus" //bitflag #defines for return value of the radio() proc. #define ITALICS (1<<0) diff --git a/code/__DEFINES/subsystems.dm b/code/__DEFINES/subsystems.dm index 944f6ef36c98..7037110cf2e4 100644 --- a/code/__DEFINES/subsystems.dm +++ b/code/__DEFINES/subsystems.dm @@ -138,6 +138,7 @@ #define INIT_ORDER_QUIRKS 73 #define INIT_ORDER_EVENTS 70 #define INIT_ORDER_JOBS 65 +#define INIT_ORDER_TTS 62 #define INIT_ORDER_MAPPING 60 #define INIT_ORDER_TICKER 50 #define INIT_ORDER_EARLY_ASSETS 48 @@ -202,6 +203,7 @@ #define FIRE_PRIORITY_TGUI 110 #define FIRE_PRIORITY_TICKER 200 #define FIRE_PRIORITY_ATMOS_ADJACENCY 300 +#define FIRE_PRIORITY_TTS 390 #define FIRE_PRIORITY_CHAT 400 #define FIRE_PRIORITY_RUNECHAT 410 #define FIRE_PRIORITY_OVERLAYS 500 diff --git a/code/__HELPERS/mobs.dm b/code/__HELPERS/mobs.dm index 3a65a6a38836..02393397894f 100644 --- a/code/__HELPERS/mobs.dm +++ b/code/__HELPERS/mobs.dm @@ -248,6 +248,43 @@ 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 East Coast American Female)", + "US-kusal" = "Kusal (Asian American Male)", + "US-libritts_r" = "Libritts (Michigan Female)", + // Custom voices down here + "US-josef" = "Josef (Canadian Male)", + "US-andrew" = "Andrew (East Coast American Male)", + "US-cameron" = "Cameron (West Coast American Male)" + ))) +GLOBAL_PROTECT(tts_voices_names) + +// Don't list custom voices on this list, they will be fetched by the API +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/text.dm b/code/__HELPERS/text.dm index 99f3c0077adb..cc15396259f7 100644 --- a/code/__HELPERS/text.dm +++ b/code/__HELPERS/text.dm @@ -849,3 +849,8 @@ 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/static/regex/regex = new(@"[^a-zA-Z0-9,._+:@%/\- ]","g") + return replacetext(txt, 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 d346e7fae044..238d673c6f0a 100644 --- a/code/__HELPERS/unsorted.dm +++ b/code/__HELPERS/unsorted.dm @@ -1260,11 +1260,11 @@ GLOBAL_DATUM_INIT(dview_mob, /mob/dview, new) return FALSE return TRUE -#define UNTIL(X) while(!(X)) stoplag() - /proc/pass(...) return +#define UNTIL(X) while(!(X)) stoplag() + /proc/get_mob_or_brainmob(occupant) var/mob/living/mob_occupant diff --git a/code/controllers/configuration/entries/game_options.dm b/code/controllers/configuration/entries/game_options.dm index 2ac99ba992bf..2174b9546f1b 100644 --- a/code/controllers/configuration/entries/game_options.dm +++ b/code/controllers/configuration/entries/game_options.dm @@ -446,3 +446,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/tts.dm b/code/controllers/subsystem/tts.dm new file mode 100644 index 000000000000..02d78b532490 --- /dev/null +++ b/code/controllers/subsystem/tts.dm @@ -0,0 +1,201 @@ +#define DEFAULT_TTS_VOLUME 80 +#define DEFAULT_TTS_VOLUME_RADIO 25 +#define TTS_LOUDMODE_MULTIPLIER 1.5 +#define TTS_COLOSSUS_MULTIPLIER 3 + +SUBSYSTEM_DEF(tts) + name = "Text-to-Speech" + init_order = INIT_ORDER_TTS + + priority = FIRE_PRIORITY_TTS + runlevels = RUNLEVEL_LOBBY | RUNLEVELS_DEFAULT + + // TTS backend is not set to build in CI, but we want it to scream if it fails in real lobby + flags = SS_OK_TO_FAIL_INIT + + var/pinging_tts = FALSE + var/tts_alive = FALSE + var/tts_capped = FALSE + var/list/active_processing // Prevents us from queueing the same message twice, resulting in probable errors + +/atom/movable + var/tts_voice + var/tts_pitch = 1 + var/list/tts_filters + +/datum/controller/subsystem/tts/Initialize(timeofday) + if(!CONFIG_GET(flag/tts_enable)) + return SS_INIT_NO_NEED + if(!ping_tts()) + return SS_INIT_FAILURE + + // 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() + + active_processing = list() + return SS_INIT_SUCCESS + +/datum/controller/subsystem/tts/fire() + if(CONFIG_GET(flag/tts_enable) && !pinging_tts) + INVOKE_ASYNC(src, PROC_REF(ping_tts)) + +/// Ping TTS API - If we don't get a response, shut down TTS +/datum/controller/subsystem/tts/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 + + return tts_alive + +/** +* @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) +* +* @param {list} filters - Audio filters; See tts_filters.dm +* +* @param {list} receivers - List of weakrefs to receivers that should hear the message once completed +* +* @returns {sound/} or FALSE +*/ +/datum/controller/subsystem/tts/proc/create_message(message, model, pitch = 1, list/filters = list(), list/receivers = list(), source, list/spans = list(), list/message_mods = list()) + if(spans[SPAN_ITALICS] || message_mods[RADIO_EXTENSION] || message_mods[MODE_HEADSET] || message_mods[WHISPER_MODE]) + return FALSE // Mutes any whispers or radio inputs, to both avoid duplication and asterisk spam + if(spans[SPAN_CLOWN]) + pitch *= 1.5 + pitch = clamp(pitch, 0.5, 2) + message = tts_filter(message) // Phonetically correct the message (NOT SANITIZATION!) + var/sound/tts_sound_result = create_message_audio(message, model, pitch, filters) + if(!tts_sound_result) + return FALSE + if(!istype(tts_sound_result)) + CRASH(tts_sound_result) + + for(var/datum/weakref/ref in receivers) + var/mob/hearer = ref.resolve() + if(!hearer || !istype(hearer)) + continue + if(isnewplayer(hearer)) + continue + if(!isobserver(hearer) && hearer.stat >= UNCONSCIOUS) + continue + var/volume = 0 + if(!filters[TTS_FILTER_RADIO]) + volume = hearer.client?.prefs?.read_preference(/datum/preference/numeric/tts_volume) || DEFAULT_TTS_VOLUME + else + volume = hearer.client?.prefs?.read_preference(/datum/preference/numeric/tts_volume_radio) || DEFAULT_TTS_VOLUME_RADIO + if(filters[TTS_FILTER_MASKED]) // for some reason this combo is very loud! + volume *= 0.7 + + if(volume <= 0) + continue + + if(spans[SPAN_COMMAND] || spans[SPAN_CLOWN]) + volume *= TTS_LOUDMODE_MULTIPLIER + + if(spans[SPAN_COLOSSUS]) + volume *= TTS_COLOSSUS_MULTIPLIER + + volume = clamp(volume, 0, 100) + + if(source) + hearer.playsound_local(get_turf(source), vol = volume, S = tts_sound_result) + else + hearer.playsound_local(vol = volume, S = tts_sound_result) + + return TRUE + +/datum/controller/subsystem/tts/proc/create_message_audio(spammy_message, model, pitch, list/filters) + if(!CONFIG_GET(flag/tts_enable)) + return FALSE + + var/player_count = living_player_count() + if(!tts_capped && player_count >= CONFIG_GET(number/tts_cap_shutoff)) + tts_capped = TRUE + return FALSE + + if(tts_capped) + if(player_count < CONFIG_GET(number/tts_uncap_reboot)) + tts_capped = FALSE + else + return FALSE + + if(!tts_alive || !can_fire || init_stage > Master.init_stage_completed) + return FALSE + + /// anti spam + /// Replaces any 3 or more consecutive characters with 2 consecutive characters + var/static/regex/antispam_regex = new(@"(?=(.)\1\1).","g") + /// We do not want to sanitize the message, as it goes in JSON body and is not exposed directly to CMD + var/san_message = replacetext(spammy_message, antispam_regex, "") + /// We do want to sanitize the model + 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(file_name in active_processing) + return FALSE + + if(fexists(file_name)) + return sound(file_name) + + active_processing |= 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()) + + active_processing &= ~file_name + + var/datum/http_response/response = request.into_response() + if(response.errored) + fdel(file_name) + return "TTS ERRORED: [response.error]" + + if(response.status_code > 299) + fdel(file_name) + return "TTS HTTP ERROR [response.status_code]: [response.body]" + + if(response.body == "bad auth" || response.body == "missing args" || response.body == "model not found") + fdel(file_name) + return "TTS BAD REQUEST: [response.body]" + + var/sound/tts_sound = sound(file_name) + + return tts_sound diff --git a/code/datums/brain_damage/special.dm b/code/datums/brain_damage/special.dm index f8efbfe4a24a..3d83b3fb73ca 100644 --- a/code/datums/brain_damage/special.dm +++ b/code/datums/brain_damage/special.dm @@ -46,7 +46,7 @@ message = pick_list_replacements(BRAIN_DAMAGE_FILE, "god_neutral") playsound(get_turf(owner), 'sound/magic/clockwork/invoke_general.ogg', 200, 1, 5) - voice_of_god(message, owner, list("colossus","yell"), 2.5, include_owner, FALSE) + voice_of_god(message, owner, list(SPAN_COLOSSUS,"yell"), 2.5, include_owner, FALSE) /datum/brain_trauma/special/bluespace_prophet name = "Bluespace Prophecy" diff --git a/code/game/machinery/_machinery.dm b/code/game/machinery/_machinery.dm index e1f86a50aff9..49a12d088c11 100644 --- a/code/game/machinery/_machinery.dm +++ b/code/game/machinery/_machinery.dm @@ -146,6 +146,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) @@ -386,7 +388,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) @@ -499,7 +501,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].")) @@ -546,7 +548,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..6a88853fc3e4 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/list/mob/tts_receivers = list() + // Render the message and have everybody hear it. // Always call this on the virtualspeaker to avoid issues. var/spans = data["spans"] @@ -194,6 +205,12 @@ 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(hearing_mob.client?.prefs?.read_preference(/datum/preference/toggle/tts_hear_radio) && hearing_mob.has_language(language)) + tts_receivers |= WEAKREF(hearing_mob) + + INVOKE_ASYNC(SStts, TYPE_PROC_REF(/datum/controller/subsystem/tts, create_message), html_decode(message), model, pitch, virt.virt_tts_filters, tts_receivers, null, spans) // This following recording is intended for research and feedback in the use of department radio channels if(length(receive)) diff --git a/code/game/objects/items/devices/taperecorder.dm b/code/game/objects/items/devices/taperecorder.dm index cafa41b6cf9a..4ba5cdb86a42 100644 --- a/code/game/objects/items/devices/taperecorder.dm +++ b/code/game/objects/items/devices/taperecorder.dm @@ -197,16 +197,16 @@ break if(mytape.storedinfo.len < i) break - say(mytape.storedinfo[i]) + visible_message(mytape.storedinfo[i]) if(mytape.storedinfo.len < i + 1) playsleepseconds = 1 sleep(1 SECONDS) - say("End of recording.") + visible_message("End of recording.") else playsleepseconds = mytape.timestamp[i + 1] - mytape.timestamp[i] if(playsleepseconds > 14) sleep(1 SECONDS) - say("Skipping [playsleepseconds] seconds of silence") + visible_message("Skipping [playsleepseconds] seconds of silence") playsleepseconds = 1 i++ diff --git a/code/game/objects/items/robot/robot_parts.dm b/code/game/objects/items/robot/robot_parts.dm index f99ee5170004..749efd12721d 100644 --- a/code/game/objects/items/robot/robot_parts.dm +++ b/code/game/objects/items/robot/robot_parts.dm @@ -335,7 +335,7 @@ REMOVE_TRAIT(O, TRAIT_PACIFISM, POSIBRAIN_TRAIT) // remove the posibrain's pacifism - O.updatename(BM.client) + O.update_cyborg_prefs(BM.client) BM.mind.transfer_to(O) diff --git a/code/game/objects/items/robot/robot_upgrades.dm b/code/game/objects/items/robot/robot_upgrades.dm index 1e1e17394b46..8b7e1c18ad38 100644 --- a/code/game/objects/items/robot/robot_upgrades.dm +++ b/code/game/objects/items/robot/robot_upgrades.dm @@ -25,7 +25,7 @@ var/repeatable = FALSE /// If the cyborg doesn't have all of these upgrades, they are prevented from receiving this upgrade. var/list/prerequisite_upgrades = null - /// If the cyborg has any of these upgrades, they are prevented from receiving this upgrade. + /// If the cyborg has any of these upgrades, they are prevented from receiving this upgrade. var/list/blacklisted_upgrades = null /// Called when upgrade is used on the cyborg. @@ -96,11 +96,11 @@ if(!length(heldname)) // Hugbox against people that forgot to set the name. to_chat(user, span_notice("This board doesn't have a name set!")) return FALSE - + var/oldname = R.real_name var/oldkeyname = key_name(R) R.custom_name = heldname - R.updatename() + R.update_cyborg_prefs() if(oldname != R.real_name) // Name is different now. R.notify_ai(RENAME, oldname, R.real_name) log_game("[key_name(user)] used a cyborg reclassification board to rename [oldkeyname] to [key_name(R)] at [loc_name(user)]") // Should be logged even if no change. @@ -327,7 +327,7 @@ if(DD) to_chat(user, span_notice("This cyborg already has a diamond drill built into them!")) return FALSE - + DD = new(R.module) R.module.basic_modules += DD R.module.add_module(DD, FALSE, TRUE) @@ -541,7 +541,7 @@ . = ..() if(!.) return FALSE - + toggle_action.Remove(R) QDEL_NULL(toggle_action) deactivate_sr() @@ -605,7 +605,7 @@ if(!COOLDOWN_FINISHED(src, next_message)) return - + COOLDOWN_START(src, next_message, message_cooldown) var/msgmode = "standby" if(cyborg.health < 0) @@ -732,7 +732,7 @@ . = ..() if(!.) return FALSE - + for(var/obj/item/reagent_containers/borghypo/H in R.module.modules) H.bypass_protection = initial(H.bypass_protection) @@ -830,7 +830,7 @@ . = ..() if(!.) return FALSE - + for(var/obj/item/healthanalyzer/advanced/advanalyzer in R.module.modules) R.module.remove_module(advanalyzer, TRUE) @@ -895,7 +895,7 @@ . = ..() if(!.) return FALSE - + for(var/obj/item/scalpel/advanced/SE in R.module.modules) R.module.remove_module(SE, TRUE) @@ -1072,7 +1072,7 @@ . = ..() if(!.) return FALSE - + for(var/obj/item/storage/part_replacer/bluespace/cyborg/BRPED in R.module.modules) BRPED.emptyStorage() R.module.remove_module(BRPED, TRUE) @@ -1110,7 +1110,7 @@ . = ..() if(!.) return FALSE - + for(var/obj/item/gun/energy/plasmacutter/adv/cyborg/PC in R.module.modules) R.module.remove_module(PC, TRUE) @@ -1206,7 +1206,7 @@ . = ..() if(!.) return FALSE - + for(var/obj/item/borg_snack_dispenser/snack_dispenser in R.module.modules) R.module.remove_module(snack_dispenser, TRUE) @@ -1405,7 +1405,7 @@ . = ..() if(!.) return FALSE - + for(var/obj/item/borg/sight/meson/nightvision/nvgmeson in R.module.modules) R.module.remove_module(nvgmeson, TRUE) diff --git a/code/game/say.dm b/code/game/say.dm index 45410e3f1656..1867c25ef520 100644 --- a/code/game/say.dm +++ b/code/game/say.dm @@ -21,7 +21,7 @@ GLOBAL_LIST_INIT(freqtospan, list( //yogs end )) -/atom/movable/proc/say(message, bubble_type, list/spans = list(), sanitize = TRUE, datum/language/language = null, ignore_spam = FALSE, forced = null) +/atom/movable/proc/say(message, bubble_type, list/spans = list(), sanitize = TRUE, datum/language/language = null, ignore_spam = FALSE, forced = null, ignore_tts = FALSE) if(!can_speak(message)) return if(message == "" || !message) @@ -39,9 +39,21 @@ 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(GetTTSVoice())) // Sanitize with an immutable list + tts_voice = pick(GLOB.tts_voices) + tts_pitch = rand(8, 12) * 0.1 + + var/list/mob/tts_receivers = list() 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(hearing_mob.client?.prefs?.read_preference(/datum/preference/toggle/tts_hear) && hearing_mob.has_language(message_language)) + tts_receivers |= WEAKREF(hearing_mob) + + INVOKE_ASYNC(SStts, TYPE_PROC_REF(/datum/controller/subsystem/tts, create_message), message, GetTTSVoice(), GetTTSPitch(), tts_filters, tts_receivers, src, spans, message_mods) /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. @@ -148,6 +160,12 @@ GLOBAL_LIST_INIT(freqtospan, list( return "2" return "0" +/atom/movable/proc/GetTTSVoice() + return tts_voice + +/atom/movable/proc/GetTTSPitch() + return tts_pitch + /atom/movable/proc/GetVoice() return "[src]" //Returns the atom's name, prepended with 'The' if it's not a proper noun @@ -171,6 +189,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 +205,12 @@ INITIALIZE_IMMEDIATE(/atom/movable/virtualspeaker) verb_ask = M.verb_ask verb_exclaim = M.verb_exclaim verb_yell = M.verb_yell + virt_tts_voice = M.GetTTSVoice() + virt_tts_pitch = M.GetTTSPitch() + virt_tts_filters = LAZYCOPY(M.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/game/world.dm b/code/game/world.dm index 65b4cbdc23a3..4e26c8a04361 100644 --- a/code/game/world.dm +++ b/code/game/world.dm @@ -62,7 +62,7 @@ GLOBAL_VAR(restart_counter) if(CONFIG_GET(flag/usewhitelist)) load_whitelist() - setup_pretty_filter() //yogs + setup_pretty_filters() //yogs GLOB.timezoneOffset = text2num(time2text(0,"hh")) * 36000 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.dm b/code/modules/client/preferences.dm index 8ba670c445a3..362847b4c86e 100644 --- a/code/modules/client/preferences.dm +++ b/code/modules/client/preferences.dm @@ -25,7 +25,7 @@ GLOBAL_LIST_EMPTY(preferences_datums) /// Cached list of keybindings, mapping keys to actions. /// For example, by default would have "X" -> list("swap_hands") var/list/key_bindings_by_key = list() - + var/toggles = TOGGLES_DEFAULT var/db_flags var/chat_toggles = TOGGLES_DEFAULT_CHAT @@ -104,7 +104,7 @@ GLOBAL_LIST_EMPTY(preferences_datums) unlock_content |= DONOR_BYOND // the latter handles race cases where the prefs are not fully loaded in, or GLOB.donators hasn't loaded in yet - if(is_donator(C) || (C.ckey in get_donators())) + if(is_donator(C) || (C.ckey in get_donators())) unlock_content |= DONOR_YOGS // give save slots to donors @@ -175,6 +175,8 @@ GLOBAL_LIST_EMPTY(preferences_datums) data["character_preferences"] = compile_character_preferences(user) + data["tts_preview_disabled"] = !SStts.tts_alive || !SStts.can_fire || SStts.init_stage > Master.init_stage_completed + data["active_slot"] = default_slot for (var/datum/preference_middleware/preference_middleware as anything in middleware) @@ -290,6 +292,26 @@ GLOBAL_LIST_EMPTY(preferences_datums) return TRUE + if("preview_tts") + if(!SStts.tts_alive || !SStts.can_fire || SStts.init_stage > Master.init_stage_completed) + return FALSE + var/model = read_preference(/datum/preference/choiced/tts_voice) + var/pitch = read_preference(/datum/preference/numeric/tts_pitch) + var/list/filters + var/datum/job/preview_job = get_highest_priority_job() + if(preview_job) + if (istype(preview_job, /datum/job/ai)) + filters = list(TTS_FILTER_ROBOTIC = TRUE, TTS_FILTER_RADIO = TRUE) + if (istype(preview_job, /datum/job/cyborg)) + filters = list(TTS_FILTER_ROBOTIC = TRUE) + + if(!filters) + filters = character_preview_view.body.tts_filters + + INVOKE_ASYNC(SStts, TYPE_PROC_REF(/datum/controller/subsystem/tts, create_message), "How are you doing today?", model, pitch, filters, list(WEAKREF(usr))) + + return + for (var/datum/preference_middleware/preference_middleware as anything in middleware) var/delegation = preference_middleware.action_delegations[action] if (!isnull(delegation)) @@ -526,7 +548,7 @@ INITIALIZE_IMMEDIATE(/atom/movable/screen/character_preview_view) continue preference.apply_to_human(character, read_preference(preference.type)) - + character.dna.real_name = character.real_name if(icon_updates) 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..5c6167edff6a --- /dev/null +++ b/code/modules/client/preferences/tts_pitch.dm @@ -0,0 +1,14 @@ +/datum/preference/numeric/tts_pitch + category = PREFERENCE_CATEGORY_SECONDARY_FEATURES + savefile_identifier = PREFERENCE_CHARACTER + savefile_key = "tts_pitch" + + minimum = 0.9 + maximum = 1.1 + step = 0.05 + +/datum/preference/numeric/tts_pitch/create_default_value() + return rand(minimum * 10, maximum * 10) / 10 + +/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/client/preferences/tts_volume.dm b/code/modules/client/preferences/tts_volume.dm new file mode 100644 index 000000000000..90bad16b0f89 --- /dev/null +++ b/code/modules/client/preferences/tts_volume.dm @@ -0,0 +1,11 @@ +/// Controls the volume at which the user hears in-person TTS +/datum/preference/numeric/tts_volume + category = PREFERENCE_CATEGORY_GAME_PREFERENCES + savefile_identifier = PREFERENCE_PLAYER + savefile_key = "tts_volume" + + minimum = 0 + maximum = 100 + +/datum/preference/numeric/tts_volume/create_default_value() + return DEFAULT_TTS_VOLUME diff --git a/code/modules/client/preferences/tts_volume_radio.dm b/code/modules/client/preferences/tts_volume_radio.dm new file mode 100644 index 000000000000..1cbc99e0451b --- /dev/null +++ b/code/modules/client/preferences/tts_volume_radio.dm @@ -0,0 +1,11 @@ +/// Controls the volume at which the user hears radio TTS +/datum/preference/numeric/tts_volume_radio + category = PREFERENCE_CATEGORY_GAME_PREFERENCES + savefile_identifier = PREFERENCE_PLAYER + savefile_key = "tts_volume_radio" + + minimum = 0 + maximum = 100 + +/datum/preference/numeric/tts_volume_radio/create_default_value() + return DEFAULT_TTS_VOLUME_RADIO diff --git a/code/modules/clothing/chameleon.dm b/code/modules/clothing/chameleon.dm index 43562cd6d269..749c22cc0f18 100644 --- a/code/modules/clothing/chameleon.dm +++ b/code/modules/clothing/chameleon.dm @@ -312,7 +312,7 @@ /obj/item/clothing/under/chameleon/broken/Initialize(mapload) . = ..() chameleon_action.emp_randomise(INFINITY) - + /obj/item/clothing/under/plasmaman/chameleon name = "envirosuit" icon_state = "plasmaman" @@ -481,7 +481,7 @@ /obj/item/clothing/head/chameleon/broken/Initialize(mapload) . = ..() chameleon_action.emp_randomise(INFINITY) - + /obj/item/clothing/head/helmet/space/plasmaman/chameleon name = "purple envirosuit helmet" desc = "A generic purple envirohelm of Nanotrasen design. This updated model comes with a built-in lamp." @@ -541,6 +541,7 @@ var/vchange = 1 var/datum/action/item_action/chameleon/change/chameleon_action + var/datum/action/item_action/mask_voice_change/voice_action /obj/item/clothing/mask/chameleon/syndicate syndicate = TRUE @@ -548,23 +549,30 @@ /obj/item/clothing/mask/chameleon/Initialize(mapload) . = ..() chameleon_action = new(src) + voice_action = new(src) if(syndicate) chameleon_action.syndicate = TRUE + voice_action.syndicate = TRUE chameleon_action.chameleon_type = /obj/item/clothing/mask chameleon_action.chameleon_name = "Mask" chameleon_action.chameleon_blacklist = typecacheof(/obj/item/clothing/mask/changeling, only_root_path = TRUE) chameleon_action.initialize_disguises() + voice_action.initialize_disguises() add_item_action(chameleon_action) + if(vchange) // dont give this button to drones, it wouldn't do anything + add_item_action(voice_action) /obj/item/clothing/mask/chameleon/emp_act(severity) . = ..() if(. & EMP_PROTECT_SELF) return chameleon_action.emp_randomise() + voice_action.emp_randomise() /obj/item/clothing/mask/chameleon/broken/Initialize(mapload) . = ..() chameleon_action.emp_randomise(INFINITY) + voice_action.emp_randomise(INFINITY) /obj/item/clothing/mask/chameleon/attack_self(mob/user) vchange = !vchange @@ -776,3 +784,97 @@ /obj/item/proc/on_chameleon_change() return + +/// Item action for changing voice (TTS) +/datum/action/item_action/mask_voice_change + name = "Change Voice" + var/list/voice_list = list() + var/list/voice_display = list() + var/emp_timer + var/current_voice = null + var/current_pitch = 1 + var/syndicate = FALSE + +/datum/action/item_action/mask_voice_change/Grant(mob/M) + if(syndicate) + owner_has_control = is_syndicate(M) + return ..() + +/datum/action/item_action/mask_voice_change/proc/initialize_disguises() + build_all_button_icons() + voice_list = LAZYCOPY(GLOB.tts_voices) + voice_display = LAZYCOPY(GLOB.tts_voices_names) + random_look() + +/datum/action/item_action/mask_voice_change/proc/random_look() + current_voice = pick(voice_list) + current_pitch = rand(16, 24) * 0.05 // 0.8-1.2 + +/datum/action/item_action/mask_voice_change/Trigger(trigger_flags) + if(!IsAvailable(feedback = TRUE)) + return FALSE + if(SEND_SIGNAL(src, COMSIG_ACTION_TRIGGER, src) & COMPONENT_ACTION_BLOCK_TRIGGER) + return FALSE + ui_interact(owner) + +/datum/action/item_action/mask_voice_change/proc/emp_randomise(amount = EMP_RANDOMISE_TIME) + START_PROCESSING(SSprocessing, src) + random_look() + + var/new_value = world.time + amount + if(new_value > emp_timer) + emp_timer = new_value + +/datum/action/item_action/mask_voice_change/process() + if(world.time > emp_timer) + STOP_PROCESSING(SSprocessing, src) + return + random_look() + +/datum/action/item_action/mask_voice_change/ui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if(!IsAvailable(feedback = TRUE)) + return FALSE + if(SEND_SIGNAL(src, COMSIG_ACTION_TRIGGER, src) & COMPONENT_ACTION_BLOCK_TRIGGER) + return FALSE + if(!ui) + ui = new(user, src, "VoiceChanger", name) + ui.open() + return TRUE + +/datum/action/item_action/mask_voice_change/ui_status(mob/user) + return user == owner ? UI_INTERACTIVE : UI_CLOSE + +/datum/action/item_action/mask_voice_change/ui_data(mob/user) + var/list/data = ..() + data["voice_list"] = voice_list + data["voice_display"] = voice_display + data["current_voice"] = current_voice + data["current_pitch"] = current_pitch + data["emp"] = world.time <= emp_timer + return data + +/datum/action/item_action/mask_voice_change/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + if(..()) + return TRUE + + if(!IsAvailable(feedback = FALSE)) + return FALSE + + if(world.time <= emp_timer) + return FALSE + + switch(action) + if("change_voice") + var/new_voice = params["voice"] + if(!(new_voice in voice_list)) + return FALSE + current_voice = new_voice + return TRUE + + if("change_pitch") + var/new_pitch = text2num(params["pitch"]) + if(!isnum(new_pitch)) + return FALSE + current_pitch = clamp(new_pitch, 0.8, 1.2) + return TRUE diff --git a/code/modules/clothing/masks/_masks.dm b/code/modules/clothing/masks/_masks.dm index 63fbf97f7790..b8523748ddf0 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/jobs/job_types/ai.dm b/code/modules/jobs/job_types/ai.dm index f57b02b140db..b3c070fc7272 100644 --- a/code/modules/jobs/job_types/ai.dm +++ b/code/modules/jobs/job_types/ai.dm @@ -32,7 +32,7 @@ /datum/job/ai/after_spawn(mob/H, mob/M, latejoin) . = ..() - + var/mob/living/silicon/ai/AI = H AI.relocate(TRUE) @@ -45,6 +45,7 @@ AI.apply_pref_name(/datum/preference/name/ai, M.client) //If this runtimes oh well jobcode is fucked. AI.set_core_display_icon(null, M.client) + AI.update_accent_and_pitch(M.client) //we may have been created after our borg if(SSticker.current_state == GAME_STATE_SETTING_UP) diff --git a/code/modules/jobs/job_types/cyborg.dm b/code/modules/jobs/job_types/cyborg.dm index c1b202ae57f1..1980613a5e13 100644 --- a/code/modules/jobs/job_types/cyborg.dm +++ b/code/modules/jobs/job_types/cyborg.dm @@ -28,7 +28,7 @@ /datum/job/cyborg/after_spawn(mob/living/silicon/robot/R, mob/M) . = ..() - R.updatename(M.client) + R.update_cyborg_prefs(M.client) R.gender = NEUTER /datum/job/cyborg/radio_help_message(mob/M) diff --git a/code/modules/mob/living/carbon/human/say.dm b/code/modules/mob/living/carbon/human/say.dm index 29e8b3326d14..c0196c34ac80 100644 --- a/code/modules/mob/living/carbon/human/say.dm +++ b/code/modules/mob/living/carbon/human/say.dm @@ -4,9 +4,23 @@ verb_say = rare_verb else verb_say = dna.species.say_mod - + . = ..() +/mob/living/carbon/human/GetTTSVoice() + if(istype(wear_mask, /obj/item/clothing/mask/chameleon)) + var/obj/item/clothing/mask/chameleon/V = wear_mask + if(V.vchange && V.voice_action?.current_voice) + return V.voice_action.current_voice + return ..() + +/mob/living/carbon/human/GetTTSPitch() + if(istype(wear_mask, /obj/item/clothing/mask/chameleon)) + var/obj/item/clothing/mask/chameleon/V = wear_mask + if(V.vchange && V.voice_action?.current_pitch) + return V.voice_action.current_pitch + return ..() + /mob/living/carbon/human/GetVoice() if(istype(wear_mask, /obj/item/clothing/mask/chameleon)) var/obj/item/clothing/mask/chameleon/V = wear_mask 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 b1193b2b63b6..097773e659ce 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 mutanteyes = /obj/item/organ/eyes/ethereal exotic_blood = /datum/reagent/consumable/liquidelectricity //Liquid Electricity. fuck you think of something better gamer exotic_bloodtype = "E" //this isn't used for anything other than bloodsplatter colours diff --git a/code/modules/mob/living/say.dm b/code/modules/mob/living/say.dm index 0aa25a9049cc..84ac7477455c 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,6 +305,15 @@ 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(GetTTSVoice())) // Sanitize with an immutable list + tts_voice = pick(GLOB.tts_voices) + + if(!GetTTSPitch() || !isnum(GetTTSPitch())) + tts_pitch = rand(8, 12) * 0.1 + + var/list/mob/tts_receivers = list() + var/rendered = compose_message(src, message_language, message, , spans, message_mods) for(var/_AM in listening) var/atom/movable/AM = _AM @@ -312,6 +321,13 @@ GLOBAL_LIST_INIT(special_radio_keys, list( AM.Hear(eavesrendered, src, message_language, eavesdropping, , spans, message_mods) else AM.Hear(rendered, src, message_language, message, , spans, message_mods) + if(ismob(AM)) + var/mob/hearing_mob = AM + if(hearing_mob.client?.prefs?.read_preference(/datum/preference/toggle/tts_hear) && hearing_mob.has_language(message_language)) + tts_receivers |= WEAKREF(hearing_mob) + + INVOKE_ASYNC(SStts, TYPE_PROC_REF(/datum/controller/subsystem/tts, create_message), html_decode(message), GetTTSVoice(), GetTTSPitch(), tts_filters, tts_receivers, src, spans, message_mods) + SEND_GLOBAL_SIGNAL(COMSIG_GLOB_LIVING_SAY_SPECIAL, src, message) //speech bubble @@ -388,7 +404,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/ai/ai.dm b/code/modules/mob/living/silicon/ai/ai.dm index e2c4194c4c2f..8885edab3d71 100644 --- a/code/modules/mob/living/silicon/ai/ai.dm +++ b/code/modules/mob/living/silicon/ai/ai.dm @@ -167,6 +167,7 @@ INVOKE_ASYNC(src, PROC_REF(apply_pref_name), /datum/preference/name/ai, client) INVOKE_ASYNC(src, PROC_REF(set_core_display_icon)) + INVOKE_ASYNC(src, PROC_REF(update_accent_and_pitch)) holo_icon = getHologramIcon(icon('icons/mob/ai.dmi',"default")) @@ -238,6 +239,18 @@ fire_stacks = 0 . = ..() +// Used only for AI and cyborgs, because character prefs typically only apply to humans +/mob/proc/update_accent_and_pitch(client/C) + if(client && !C) + C = client + + var/voice_pref = C?.prefs?.read_preference(/datum/preference/choiced/tts_voice) + if(voice_pref) + tts_voice = voice_pref + var/pitch_pref = C?.prefs?.read_preference(/datum/preference/numeric/tts_pitch) + if(pitch_pref) + tts_pitch = pitch_pref + /mob/living/silicon/ai/proc/set_core_display_icon(input, client/C) if(client && !C) C = client diff --git a/code/modules/mob/living/silicon/robot/robot.dm b/code/modules/mob/living/silicon/robot/robot.dm index ea28cd315e22..c825ed2ec8aa 100644 --- a/code/modules/mob/living/silicon/robot/robot.dm +++ b/code/modules/mob/living/silicon/robot/robot.dm @@ -172,7 +172,7 @@ mmi.brainmob.container = mmi mmi.update_appearance(UPDATE_ICON) - updatename() + update_cyborg_prefs() blacklisted_hats = typecacheof(blacklisted_hats) @@ -252,11 +252,14 @@ module.transform_to(modulelist[input_module]) -/mob/living/silicon/robot/proc/updatename(client/C) +/mob/living/silicon/robot/proc/update_cyborg_prefs(client/C) if(shell) return if(!C) C = client + + update_accent_and_pitch(C) + var/changed_name = "" if(custom_name) changed_name = custom_name @@ -1018,7 +1021,7 @@ cut_overlay(GLOB.fire_appearances[fire_icon]) return null - + /mob/living/silicon/robot/updatehealth() ..() @@ -1163,14 +1166,14 @@ // Drops all items found in any storage bags on the Cyborg. for(var/obj/item/storage/bag in module.contents) bag.emptyStorage() - + while(expansion_count) resize = 0.5 expansion_count-- update_transform() logevent("Chassis configuration has been reset.") icon = initial(icon) //Should fix invisi-donorborgs ~ Kmc - module.transform_to(/obj/item/robot_module) // Will reset armor & armor_plates as well. + module.transform_to(/obj/item/robot_module) // Will reset armor & armor_plates as well. // Remove upgrades. for(var/obj/item/borg/upgrade/I in upgrades) @@ -1204,7 +1207,7 @@ hat_offset = module.hat_offset magpulse = module.magpulsing - updatename() + update_cyborg_prefs() /mob/living/silicon/robot/proc/place_on_head(obj/item/new_hat) if(hat) diff --git a/code/modules/mob/living/silicon/silicon.dm b/code/modules/mob/living/silicon/silicon.dm index 7e2cb6838954..b53c65f32fa3 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) @@ -231,19 +232,19 @@ //laws_sanity_check() //laws.show_laws(world) var/number = 1 - sleep(1 SECONDS) + sleep(2 SECONDS) if (laws.devillaws && laws.devillaws.len) for(var/index = 1, index <= laws.devillaws.len, index++) if (force || devillawcheck[index] == "Yes") say("[radiomod] 666. [laws.devillaws[index]]") - sleep(1 SECONDS) + sleep(7 SECONDS) if (laws.zeroth) if (force || lawcheck[1] == "Yes") say("[radiomod] 0. [laws.zeroth]") - sleep(1 SECONDS) + sleep(7 SECONDS) for (var/index = 1, index <= laws.hacked.len, index++) var/law = laws.hacked[index] @@ -251,7 +252,7 @@ if (length(law) > 0) if (force || hackedcheck[index] == "Yes") say("[radiomod] [num]. [law]") - sleep(1 SECONDS) + sleep(7 SECONDS) for (var/index = 1, index <= laws.ion.len, index++) var/law = laws.ion[index] @@ -259,7 +260,7 @@ if (length(law) > 0) if (force || ioncheck[index] == "Yes") say("[radiomod] [num]. [law]") - sleep(1 SECONDS) + sleep(7 SECONDS) for (var/index = 1, index <= laws.inherent.len, index++) var/law = laws.inherent[index] @@ -268,7 +269,7 @@ if (force || lawcheck[index+1] == "Yes") say("[radiomod] [number]. [law]") number++ - sleep(1 SECONDS) + sleep(7 SECONDS) for (var/index = 1, index <= laws.supplied.len, index++) var/law = laws.supplied[index] @@ -278,7 +279,7 @@ if (force || lawcheck[number+1] == "Yes") say("[radiomod] [number]. [law]") number++ - sleep(1 SECONDS) + sleep(7 SECONDS) /mob/living/silicon/proc/checklaws() //Gives you a link-driven interface for deciding what laws the statelaws() proc will share with the crew. --NeoFite diff --git a/code/modules/mob/living/simple_animal/bot/bot.dm b/code/modules/mob/living/simple_animal/bot/bot.dm index bf7a61cc67bb..1d242c845fb1 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/mob/transform_procs.dm b/code/modules/mob/transform_procs.dm index c38f830ecdbe..6b92fe15b07b 100644 --- a/code/modules/mob/transform_procs.dm +++ b/code/modules/mob/transform_procs.dm @@ -420,7 +420,7 @@ R.invisibility = 0 if(client) - R.updatename(client) + R.update_cyborg_prefs(client) if(mind) //TODO if(!transfer_after) diff --git a/code/modules/research/rdconsole.dm b/code/modules/research/rdconsole.dm index f19753ed5eeb..f42228ad4808 100644 --- a/code/modules/research/rdconsole.dm +++ b/code/modules/research/rdconsole.dm @@ -155,11 +155,11 @@ Nothing else in the console has ID requirements. /obj/machinery/computer/rdconsole/proc/research_node(id, mob/user) if(!stored_research.available_nodes[id] || stored_research.researched_nodes[id]) - say("Node unlock failed: Either already researched or not available!") + visible_message("Node unlock failed: Either already researched or not available!") return FALSE var/datum/techweb_node/TN = SSresearch.techweb_node_by_id(id) if(!istype(TN)) - say("Node unlock failed: Unknown error.") + visible_message("Node unlock failed: Unknown error.") return FALSE var/list/price = TN.get_price(stored_research) if(stored_research.can_afford(price)) @@ -167,7 +167,7 @@ Nothing else in the console has ID requirements. if(stored_research == SSresearch.science_tech) SSblackbox.record_feedback("associative", "science_techweb_unlock", 1, list("id" = "[id]", "name" = TN.display_name, "price" = "[json_encode(price)]", "time" = SQLtime())) if(stored_research.research_node_id(id)) - say("Successfully researched [TN.display_name].") + visible_message("Successfully researched [TN.display_name].") var/logname = "Unknown" if(isAI(user)) logname = "AI: [user.name]" @@ -187,9 +187,9 @@ Nothing else in the console has ID requirements. stored_research.research_logs[++i] = list(TN.display_name, price["General Research"], logname, "[get_area(src)] ([src.x],[src.y],[src.z])") return TRUE else - say("Failed to research node: Internal database error!") + visible_message("Failed to research node: Internal database error!") return FALSE - say("Not enough research points...") + visible_message("Not enough research points...") return FALSE /obj/machinery/computer/rdconsole/on_deconstruction() @@ -892,23 +892,23 @@ Nothing else in the console has ID requirements. to_chat(usr, span_boldwarning("Unauthorized Access.")) if(ls["find_device"]) SyncRDevices() - say("Resynced with nearby devices.") + visible_message("Resynced with nearby devices.") if(ls["back_screen"]) back = text2num(ls["back_screen"]) if(ls["build"]) //Causes the Protolathe to build something. if(QDELETED(linked_lathe)) - say("No Protolathe Linked!") + visible_message("No Protolathe Linked!") return if(linked_lathe.busy) - say("Warning: Protolathe busy!") + visible_message("Warning: Protolathe busy!") else linked_lathe.user_try_print_id(ls["build"], ls["amount"]) if(ls["imprint"]) if(QDELETED(linked_imprinter)) - say("No Circuit Imprinter Linked!") + visible_message("No Circuit Imprinter Linked!") return if(linked_imprinter.busy) - say("Warning: Imprinter busy!") + visible_message("Warning: Imprinter busy!") else linked_imprinter.user_try_print_id(ls["imprint"]) if(ls["category"]) @@ -917,72 +917,72 @@ Nothing else in the console has ID requirements. switch(ls["disconnect"]) if("destroy") if(QDELETED(linked_destroy)) - say("No Destructive Analyzer Linked!") + visible_message("No Destructive Analyzer Linked!") return linked_destroy.linked_console = null linked_destroy = null if("lathe") if(QDELETED(linked_lathe)) - say("No Protolathe Linked!") + visible_message("No Protolathe Linked!") return linked_lathe.linked_console = null linked_lathe = null if("imprinter") if(QDELETED(linked_imprinter)) - say("No Circuit Imprinter Linked!") + visible_message("No Circuit Imprinter Linked!") return linked_imprinter.linked_console = null linked_imprinter = null if(ls["eject_design"]) //Eject the design disk. eject_disk("design") screen = RDSCREEN_MENU - say("Ejecting Design Disk") + visible_message("Ejecting Design Disk") if(ls["eject_tech"]) //Eject the technology disk. eject_disk("tech") screen = RDSCREEN_MENU - say("Ejecting Technology Disk") + visible_message("Ejecting Technology Disk") if(ls["deconstruct"]) if(QDELETED(linked_destroy)) - say("No Destructive Analyzer Linked!") + visible_message("No Destructive Analyzer Linked!") return if(!linked_destroy.user_try_decon_id(ls["deconstruct"], usr)) - say("Destructive analysis failed!") + visible_message("Destructive analysis failed!") //Protolathe Materials if(ls["disposeP"]) //Causes the protolathe to dispose of a single reagent (all of it) if(QDELETED(linked_lathe)) - say("No Protolathe Linked!") + visible_message("No Protolathe Linked!") return linked_lathe.reagents.del_reagent(ls["disposeP"]) if(ls["disposeallP"]) //Causes the protolathe to dispose of all it's reagents. if(QDELETED(linked_lathe)) - say("No Protolathe Linked!") + visible_message("No Protolathe Linked!") return linked_lathe.reagents.clear_reagents() if(ls["ejectsheet"]) //Causes the protolathe to eject a sheet of material if(QDELETED(linked_lathe)) - say("No Protolathe Linked!") + visible_message("No Protolathe Linked!") return if(!linked_lathe.materials.mat_container) - say("No material storage linked to protolathe!") + visible_message("No material storage linked to protolathe!") return linked_lathe.eject_sheets(ls["ejectsheet"], ls["eject_amt"]) //Circuit Imprinter Materials if(ls["disposeI"]) //Causes the circuit imprinter to dispose of a single reagent (all of it) if(QDELETED(linked_imprinter)) - say("No Circuit Imprinter Linked!") + visible_message("No Circuit Imprinter Linked!") return linked_imprinter.reagents.del_reagent(ls["disposeI"]) if(ls["disposeallI"]) //Causes the circuit imprinter to dispose of all it's reagents. if(QDELETED(linked_imprinter)) - say("No Circuit Imprinter Linked!") + visible_message("No Circuit Imprinter Linked!") return linked_imprinter.reagents.clear_reagents() if(ls["imprinter_ejectsheet"]) //Causes the imprinter to eject a sheet of material if(QDELETED(linked_imprinter)) - say("No Circuit Imprinter Linked!") + visible_message("No Circuit Imprinter Linked!") return if(!linked_imprinter.materials.mat_container) - say("No material storage linked to circuit imprinter!") + visible_message("No material storage linked to circuit imprinter!") return var/datum/material/M = locate(ls["imprinter_ejectsheet"]) in linked_imprinter.materials.mat_container.materials linked_imprinter.eject_sheets(M, ls["eject_amt"]) @@ -999,30 +999,30 @@ Nothing else in the console has ID requirements. research_node(ls["research_node"], usr) if(ls["clear_tech"]) //Erase la on the technology disk. if(QDELETED(t_disk)) - say("No Technology Disk Inserted!") + visible_message("No Technology Disk Inserted!") return qdel(t_disk.stored_research) t_disk.stored_research = new - say("Wiping technology disk.") + visible_message("Wiping technology disk.") if(ls["copy_tech"]) //Copy some technology la from the research holder to the disk. if(QDELETED(t_disk)) - say("No Technology Disk Inserted!") + visible_message("No Technology Disk Inserted!") return stored_research.copy_research_to(t_disk.stored_research) screen = RDSCREEN_TECHDISK - say("Downloading to technology disk.") + visible_message("Downloading to technology disk.") if(ls["clear_design"]) //Erases la on the design disk. if(QDELETED(d_disk)) - say("No Design Disk Inserted!") + visible_message("No Design Disk Inserted!") return var/n = text2num(ls["clear_design"]) if(!n) for(var/i in 1 to d_disk.max_blueprints) d_disk.blueprints[i] = null - say("Wiping design disk.") + visible_message("Wiping design disk.") else var/datum/design/D = d_disk.blueprints[n] - say("Wiping design [D.name] from design disk.") + visible_message("Wiping design [D.name] from design disk.") d_disk.blueprints[n] = null if(ls["search"]) //Search for designs with name matching pattern searchstring = ls["to_search"] @@ -1034,13 +1034,13 @@ Nothing else in the console has ID requirements. screen = RDSCREEN_IMPRINTER_SEARCH if(ls["updt_tech"]) //Uple the research holder with information from the technology disk. if(QDELETED(t_disk)) - say("No Technology Disk Inserted!") + visible_message("No Technology Disk Inserted!") return - say("Uploading technology disk.") + visible_message("Uploading technology disk.") t_disk.stored_research.copy_research_to(stored_research) if(ls["copy_design"]) //Copy design from the research holder to the design disk. if(QDELETED(d_disk)) - say("No Design Disk Inserted!") + visible_message("No Design Disk Inserted!") return var/slot = text2num(ls["copy_design"]) var/datum/design/D = SSresearch.techweb_design_by_id(ls["copy_design_ID"]) @@ -1062,7 +1062,7 @@ Nothing else in the console has ID requirements. screen = RDSCREEN_DESIGNDISK if(ls["eject_item"]) //Eject the item inside the destructive analyzer. if(QDELETED(linked_destroy)) - say("No Destructive Analyzer Linked!") + visible_message("No Destructive Analyzer Linked!") return if(linked_destroy.busy) to_chat(usr, span_danger("The destructive analyzer is busy at the moment.")) @@ -1081,7 +1081,7 @@ Nothing else in the console has ID requirements. screen = RDSCREEN_TECHWEB_DESIGNVIEW if(ls["updt_design"]) //Uploads a design from disk to the techweb. if(QDELETED(d_disk)) - say("No design disk found.") + visible_message("No design disk found.") return var/n = text2num(ls["updt_design"]) if(!n) diff --git a/code/modules/spells/spell_types/self/voice_of_god.dm b/code/modules/spells/spell_types/self/voice_of_god.dm index f84d8477d379..f552bebdb965 100644 --- a/code/modules/spells/spell_types/self/voice_of_god.dm +++ b/code/modules/spells/spell_types/self/voice_of_god.dm @@ -18,7 +18,7 @@ /// The modifier put onto the power of the command var/power_mod = 1 /// A list of spans to apply to commands given - var/list/spans = list("colossus", "yell") + var/list/spans = list(SPAN_COLOSSUS, "yell") /datum/action/cooldown/spell/voice_of_god/before_cast(atom/cast_on) . = ..() 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 92b50110451d..32ed20100e38 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,6 +270,7 @@ 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)) @@ -298,6 +307,7 @@ icon_state = "tonguexeno" say_mod = "hisses" modifies_speech = TRUE + organ_tts_filters = list(TTS_FILTER_ALIEN) var/static/list/languages_possible_polysmorph = typecacheof(list( /datum/language/common, /datum/language/polysmorph)) @@ -332,3 +342,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/code/modules/surgery/organs/vocal_cords.dm b/code/modules/surgery/organs/vocal_cords.dm index b05361fec7d7..3aa70d025911 100644 --- a/code/modules/surgery/organs/vocal_cords.dm +++ b/code/modules/surgery/organs/vocal_cords.dm @@ -74,7 +74,7 @@ var/next_command = 0 var/cooldown_mod = 1 var/base_multiplier = 1 - spans = list("colossus","yell") + spans = list(SPAN_COLOSSUS,"yell") /datum/action/item_action/organ_action/colossus name = "Voice of God" @@ -143,9 +143,21 @@ if(!span_list || !span_list.len) span_list = list() + var/old_pitch = user.tts_pitch + var/old_voice = user.tts_voice + if(!old_voice) + old_voice = pick(GLOB.tts_voices) + if(!old_pitch) + old_pitch = rand(9, 11) * 0.1 + user.tts_pitch = 0.5 + user.tts_voice = "US-joe" + if(!user.say(message, spans = span_list, sanitize = FALSE)) // If we failed to speak return 0 + user.tts_pitch = old_pitch + user.tts_voice = old_voice + var/list/mob/living/listeners = list() for(var/mob/living/L in get_hearers_in_view(max_range, user)) if(L.can_hear() && !L.can_block_magic(MAGIC_RESISTANCE_HOLY) && L.stat != DEAD) diff --git a/code/modules/unit_tests/subsystem_init.dm b/code/modules/unit_tests/subsystem_init.dm index 264b36005a02..15ba71b6a76f 100644 --- a/code/modules/unit_tests/subsystem_init.dm +++ b/code/modules/unit_tests/subsystem_init.dm @@ -1,6 +1,14 @@ +/// Tests that all subsystems that need to properly initialize. +/datum/unit_test/subsystem_init + /datum/unit_test/subsystem_init/Run() - for(var/datum/controller/subsystem/master_subsystem as anything in Master.subsystems) - if(master_subsystem.flags & SS_NO_INIT) + for(var/datum/controller/subsystem/subsystem as anything in Master.subsystems) + if(subsystem.flags & SS_NO_INIT) continue - if(!master_subsystem.initialized) - TEST_FAIL("[master_subsystem]([master_subsystem.type]) is a subsystem meant to initialize but doesn't get set as initialized.") + if(!subsystem.initialized) + var/message = "[subsystem] ([subsystem.type]) is a subsystem meant to initialize but doesn't get set as initialized." + + if (subsystem.flags & SS_OK_TO_FAIL_INIT) + TEST_NOTICE(src, "[message]\nThis subsystem is marked as SS_OK_TO_FAIL_INIT. This is still a bug, but it is non-blocking.") + else + TEST_FAIL(message) diff --git a/config/config.txt b/config/config.txt index dcd8fb75bf9e..41af48b6cad3 100644 --- a/config/config.txt +++ b/config/config.txt @@ -453,3 +453,20 @@ 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 +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 +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/config/tts_filter.txt b/config/tts_filter.txt new file mode 100644 index 000000000000..dbba7d6e9973 --- /dev/null +++ b/config/tts_filter.txt @@ -0,0 +1,30 @@ +# This file contains filters required for phoenetic replacements in text-to-speech systems. +# Each line contains one pattern and one replacement. The patterns used are regular +# expression patterns. +# +# Examples: +# +# fuck=duck +# This would replace all instances of "fuck" with duck. So "fucking" would become "ducking". +# +# fu+ck=duck +# This would do the same as above, except it would accept one or more "u". So "fuuuuuck" would become "duck". +# +# Activate the ([\S]+?)=use $1 +# This would match a group, and use it in a pattern. So "Activate the probulator" would become "use probulator". +# +# \b[f]+[u]+\b=fun you +# This would make any word with combinations of "f" followed by "u" be replaced with fun you. So "ffffffffuuuuuuuuuuu" becomes "fun you". + +# Prefer to use this over I.P.C. as "IPCs" would be pronounced "I.P.C.S." +ipc=eye pee see +smes=ess em e ess +\bhos\b=h oh ess +\bhos's\b=h oh esses +\bce\b=see e +\bce's\b=see eess +xenobio=xeno bio +esword=e sword +emag=e mag +\bhm\b=hmm +\bus\b=uss diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/MainPage.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/MainPage.tsx index a9a4009d947a..d652c9913564 100644 --- a/tgui/packages/tgui/interfaces/PreferencesMenu/MainPage.tsx +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/MainPage.tsx @@ -12,11 +12,11 @@ import { FeatureChoicedServerData, FeatureValueInput } from "./preferences/featu import { filterMap, sortBy } from "common/collections"; import { useRandomToggleState } from "./useRandomToggleState"; -const CLOTHING_CELL_SIZE = 48; -const CLOTHING_SIDEBAR_ROWS = 9; +const CLOTHING_CELL_SIZE = 64; +const CLOTHING_SIDEBAR_ROWS = 10; -const CLOTHING_SELECTION_CELL_SIZE = 48; -const CLOTHING_SELECTION_WIDTH = 5.4; +const CLOTHING_SELECTION_CELL_SIZE = 64; +const CLOTHING_SELECTION_WIDTH = 6.3; const CLOTHING_SELECTION_MULTIPLIER = 5.2; const CharacterControls = (props: { @@ -594,7 +594,13 @@ export const MainPage = (props: { randomizations={getRandomization(contextualPreferences)} preferences={contextualPreferences} /> - +