diff --git a/code/__DEFINES/dcs/signals/signals_mob/signals_mob_living.dm b/code/__DEFINES/dcs/signals/signals_mob/signals_mob_living.dm index 68e7cdd11e2..c4695a2d3b7 100644 --- a/code/__DEFINES/dcs/signals/signals_mob/signals_mob_living.dm +++ b/code/__DEFINES/dcs/signals/signals_mob/signals_mob_living.dm @@ -107,7 +107,9 @@ /// From mob/living/treat_message(): (list/message_args) #define COMSIG_LIVING_TREAT_MESSAGE "living_treat_message" /// The index of message_args that corresponds to the actual message - #define TREAT_MESSAGE_MESSAGE 1 + #define TREAT_MESSAGE_ARG 1 + #define TREAT_TTS_MESSAGE_ARG 2 + #define TREAT_TTS_FILTER_ARG 3 ///From obj/item/toy/crayon/spraycan #define COMSIG_LIVING_MOB_PAINTED "living_mob_painted" diff --git a/code/__DEFINES/speech_controller.dm b/code/__DEFINES/speech_controller.dm index e758cca60f6..4b9a3f76960 100644 --- a/code/__DEFINES/speech_controller.dm +++ b/code/__DEFINES/speech_controller.dm @@ -4,9 +4,3 @@ #define SPEECH_CONTROLLER_QUEUE_WHISPER_VERB "whisper_verb" #define SPEECH_CONTROLLER_QUEUE_EMOTE_VERB "emote_verb" - -#define MOB_INDEX 1 - -#define MESSAGE_INDEX 2 - -#define CATEGORY_INDEX 3 diff --git a/code/__DEFINES/subsystems.dm b/code/__DEFINES/subsystems.dm index e490b8518d6..8a8add2739d 100644 --- a/code/__DEFINES/subsystems.dm +++ b/code/__DEFINES/subsystems.dm @@ -159,6 +159,7 @@ #define INIT_ORDER_ECONOMY 40 #define INIT_ORDER_OUTPUTS 35 #define INIT_ORDER_RESTAURANT 34 +#define INIT_ORDER_TTS 33 #define INIT_ORDER_ATOMS 30 #define INIT_ORDER_LANGUAGE 25 #define INIT_ORDER_MACHINES 20 @@ -221,6 +222,7 @@ #define FIRE_PRIORITY_STATPANEL 390 #define FIRE_PRIORITY_CHAT 400 #define FIRE_PRIORITY_RUNECHAT 410 +#define FIRE_PRIORITY_TTS 425 #define FIRE_PRIORITY_MOUSE_ENTERED 450 #define FIRE_PRIORITY_OVERLAYS 500 #define FIRE_PRIORITY_EXPLOSIONS 666 diff --git a/code/__HELPERS/heap.dm b/code/__HELPERS/heap.dm index c860b2013ca..363fbbb80d6 100644 --- a/code/__HELPERS/heap.dm +++ b/code/__HELPERS/heap.dm @@ -20,7 +20,7 @@ return !length(L) //insert and place at its position a new node in the heap -/datum/heap/proc/insert(atom/A) +/datum/heap/proc/insert(A) L.Add(A) swim(length(L)) @@ -70,7 +70,7 @@ return index * 2 //Replaces a given node so it verify the heap condition -/datum/heap/proc/resort(atom/A) +/datum/heap/proc/resort(A) var/index = L.Find(A) swim(index) diff --git a/code/__HELPERS/tts.dm b/code/__HELPERS/tts.dm new file mode 100644 index 00000000000..9d96d3d4e3e --- /dev/null +++ b/code/__HELPERS/tts.dm @@ -0,0 +1,4 @@ +/proc/tts_speech_filter(text) + // Only allow alphanumeric characters and whitespace + var/static/regex/bad_chars_regex = regex("\[^a-zA-Z0-9 ,?.!'&-]", "g") + return bad_chars_regex.Replace(text, " ") diff --git a/code/controllers/configuration/entries/game_options.dm b/code/controllers/configuration/entries/game_options.dm index 0665d86baae..5d1b46599e4 100644 --- a/code/controllers/configuration/entries/game_options.dm +++ b/code/controllers/configuration/entries/game_options.dm @@ -418,4 +418,14 @@ /datum/config_entry/flag/disallow_circuit_sounds +/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/number/tts_max_concurrent_requests + default = 4 + min_val = 1 + /datum/config_entry/flag/give_tutorials_without_db diff --git a/code/controllers/subsystem/communications.dm b/code/controllers/subsystem/communications.dm index 027d53cff3d..782810838c1 100644 --- a/code/controllers/subsystem/communications.dm +++ b/code/controllers/subsystem/communications.dm @@ -36,7 +36,8 @@ SUBSYSTEM_DEF(communications) minor_announce(html_decode(input),"[user.name] Announces:", players = players) COOLDOWN_START(src, silicon_message_cooldown, COMMUNICATION_COOLDOWN_AI) else - priority_announce(html_decode(user.treat_message(input)), null, 'sound/misc/announce.ogg', "[syndicate? "Syndicate " : ""]Captain", has_important_message = TRUE, players = players) + var/list/message_data = user.treat_message(input) + priority_announce(html_decode(message_data["message"]), null, 'sound/misc/announce.ogg', "[syndicate? "Syndicate " : ""]Captain", has_important_message = TRUE, players = players) COOLDOWN_START(src, nonsilicon_message_cooldown, COMMUNICATION_COOLDOWN) user.log_talk(input, LOG_SAY, tag="priority announcement") message_admins("[ADMIN_LOOKUPFLW(user)] has made a priority announcement.") diff --git a/code/controllers/subsystem/tts.dm b/code/controllers/subsystem/tts.dm new file mode 100644 index 00000000000..1408ef9efed --- /dev/null +++ b/code/controllers/subsystem/tts.dm @@ -0,0 +1,228 @@ +#define TARGET_INDEX 1 +#define IDENTIFIER_INDEX 2 +#define START_TIME_INDEX 3 +#define REQUEST_INDEX 4 +#define MESSAGE_INDEX 5 + +SUBSYSTEM_DEF(tts) + name = "Text To Speech" + wait = 0.05 SECONDS + priority = FIRE_PRIORITY_TTS + init_order = INIT_ORDER_TTS + runlevels = RUNLEVEL_LOBBY | RUNLEVEL_SETUP | RUNLEVEL_GAME | RUNLEVEL_POSTGAME + + /// Queued HTTP requests that have yet to be sent. TTS requests are handled as lists rather than datums. + /// It could be worth refactoring TTS messages to be datums instead to reduce complexity. + var/datum/heap/queued_tts_messages + + /// HTTP requests currently in progress but not being processed yet + var/list/in_process_tts_messages = list() + + /// HTTP requests that are being processed to see if they've been finished + var/list/current_processing_tts_messages = list() + + /// A list of available speakers, which are string identifiers of the TTS voices that can be used to generate TTS messages. + var/list/available_speakers = list() + + /// A list of current tts messages being processed, mapped by their sha1 identifier. + /// Used to prevent double processing of the same message, voice and filter, since we can just + /// cache extra requests to the current tts message being processed at once and play them upon request completion. + var/list/cached_voices = list() + + /// Whether TTS is enabled or not + var/tts_enabled = FALSE + + /// TTS messages won't play if requests took longer than this duration of time. + var/message_timeout = 7 SECONDS + + /// Messages can be timed out earlier if the algorithm thinks that + /// it's going to take too long for their message to be processed. + /// This'll determine the minimum extent of how late it is allowed to begin timing messages out + var/message_timeout_early_minimum = 5 SECONDS + + /// The max concurrent http requests that can be made at one time. Used to prevent 1 server from overloading the tts server + var/max_concurrent_requests = 4 + + /// Used to calculate the average time it takes for a tts message to be received from the http server + /// For tts messages which time out, it won't keep tracking the tts message and will just assume that the message took + /// 7 seconds (or whatever the value of message_timeout is) to receive back a response. + var/average_tts_messages_time = 0 + +/datum/controller/subsystem/tts/vv_edit_var(var_name, var_value) + // tts being enabled depends on whether it actually exists + if(NAMEOF(src, tts_enabled) == var_name) + return FALSE + return ..() + +/datum/controller/subsystem/tts/stat_entry(msg) + msg = "Active:[length(in_process_tts_messages)]|Standby:[length(queued_tts_messages.L)]|Avg:[average_tts_messages_time]" + return ..() + +/proc/cmp_word_length_asc(list/a, list/b) + return length(b[MESSAGE_INDEX]) - length(a[MESSAGE_INDEX]) + +/datum/controller/subsystem/tts/Initialize() + if(!CONFIG_GET(string/tts_http_url)) + return SS_INIT_NO_NEED + + queued_tts_messages = new /datum/heap(GLOBAL_PROC_REF(cmp_word_length_asc)) + var/datum/http_request/request = new() + var/list/headers = list() + headers["Authorization"] = CONFIG_GET(string/tts_http_token) + request.prepare(RUSTG_HTTP_METHOD_GET, "[CONFIG_GET(string/tts_http_url)]/tts-voices", "", headers) + request.begin_async() + UNTIL(request.is_complete()) + var/datum/http_response/response = request.into_response() + if(response.errored || response.status_code != 200) + stack_trace(response.error) + return SS_INIT_FAILURE + max_concurrent_requests = CONFIG_GET(number/tts_max_concurrent_requests) + available_speakers = json_decode(response.body) + tts_enabled = TRUE + rustg_file_write(json_encode(available_speakers), "data/cached_tts_voices.json") + 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") + return SS_INIT_SUCCESS + +/datum/controller/subsystem/tts/proc/play_tts(target, sound/audio, datum/language/language, local, range = 7) + if(local) + SEND_SOUND(target, audio) + return + + var/turf/turf_source = get_turf(target) + if(!turf_source) + return + + var/channel = SSsounds.random_available_channel() + var/listeners = get_hearers_in_view(range, turf_source) + + for(var/mob/listening_mob in listeners | SSmobs.dead_players_by_zlevel[turf_source.z])//observers always hear through walls + var/datum/language_holder/holder = listening_mob.get_language_holder() + if(!listening_mob.client?.prefs.read_preference(/datum/preference/toggle/sound_tts)) + continue + + if(get_dist(listening_mob, turf_source) <= range && holder.has_language(language, spoken = FALSE)) + listening_mob.playsound_local( + turf_source, + vol = (listening_mob == target)? 60 : 85, + falloff_exponent = SOUND_FALLOFF_EXPONENT, + channel = channel, + pressure_affected = TRUE, + sound_to_use = audio, + max_distance = SOUND_RANGE, + falloff_distance = SOUND_DEFAULT_FALLOFF_DISTANCE, + distance_multiplier = 1, + use_reverb = TRUE + ) + +/datum/controller/subsystem/tts/proc/handle_request(list/entry) + var/timeout_time = entry[START_TIME_INDEX] + message_timeout + if(timeout_time < world.time) + cached_voices -= entry[IDENTIFIER_INDEX] + return + var/datum/http_request/request = entry[REQUEST_INDEX] + request.begin_async() + in_process_tts_messages += list(entry) + +// Need to wait for all HTTP requests to complete here because of a rustg crash bug that causes crashes when dd restarts whilst HTTP requests are ongoing. +/datum/controller/subsystem/tts/Shutdown() + tts_enabled = FALSE + for(var/list/data in in_process_tts_messages) + var/datum/http_request/request = data[REQUEST_INDEX] + UNTIL(request.is_complete()) + +/datum/controller/subsystem/tts/fire(resumed) + if(!tts_enabled) + flags |= SS_NO_FIRE + return + + if(!resumed) + while(length(in_process_tts_messages) < max_concurrent_requests && length(queued_tts_messages.L) > 0) + var/list/entry = queued_tts_messages.pop() + handle_request(entry) + current_processing_tts_messages = in_process_tts_messages.Copy() + + // For speed + var/list/processing_messages = current_processing_tts_messages + while(processing_messages.len) + var/current_message = processing_messages[processing_messages.len] + processing_messages.len-- + var/datum/http_request/request = current_message[REQUEST_INDEX] + if(!request.is_complete()) + continue + + var/datum/http_response/response = request.into_response() + in_process_tts_messages -= list(current_message) + average_tts_messages_time = MC_AVERAGE(average_tts_messages_time, world.time - current_message[START_TIME_INDEX]) + // If it took too long to process, don't bother playing it + var/timeout_time = current_message[START_TIME_INDEX] + message_timeout + var/identifier = current_message[IDENTIFIER_INDEX] + cached_voices -= identifier + if(response.errored || timeout_time < world.time) + continue + + var/sound/new_sound = new("tmp/tts/[identifier].ogg") + for(var/target in current_message[TARGET_INDEX]) + play_tts(target["target"], new_sound, target["language"], target["local"], target["range"]) + if(MC_TICK_CHECK) + return + +#define ADD_TARGET_TO_STRUCT(tts_struct, target, language, local, range) ##tts_struct[TARGET_INDEX] += list(list("target" = ##target, "language" = ##language, "local" = ##local, "range" = ##range)) + +/datum/controller/subsystem/tts/proc/queue_tts_message(target, message, datum/language/language, speaker, filter, local = FALSE, message_range = 7) + if(!tts_enabled) + return + + var/static/regex/contains_alphanumeric = regex("\[a-zA-Z0-9]") + // If there is no alphanumeric char, the output will usually be static, so + // don't bother sending + if(contains_alphanumeric.Find(message) == 0) + return + + var/shell_scrubbed_input = tts_speech_filter(message) + shell_scrubbed_input = copytext(shell_scrubbed_input, 1, 300) + var/identifier = sha1(speaker + filter + shell_scrubbed_input) + var/cached_voice = cached_voices[identifier] + if(islist(cached_voice)) + ADD_TARGET_TO_STRUCT(cached_voice, target, language, local, message_range) + return + else if(fexists("tmp/tts/[identifier].ogg")) + var/sound/new_sound = new("tmp/tts/[identifier].ogg") + play_tts(target, new_sound, language, local, message_range) + return + if(!(speaker in available_speakers)) + return + + var/list/headers = list() + headers["Content-Type"] = "application/json" + headers["Authorization"] = CONFIG_GET(string/tts_http_token) + var/datum/http_request/request = new() + var/file_name = "tmp/tts/[identifier].ogg" + request.prepare(RUSTG_HTTP_METHOD_GET, "[CONFIG_GET(string/tts_http_url)]/tts?voice=[speaker]&identifier=[identifier]&filter=[url_encode(filter)]", json_encode(list("text" = shell_scrubbed_input)), headers, file_name) + // This'll probably be better off datumized in the future, but it's not necessary to do right now + var/list/data = list( + // TARGET_INDEX = 1 + list(), + // IDENTIFIER_INDEX = 2 + identifier, + // START_TIME_INDEX = 3 + world.time, + // REQUEST_INDEX = 4 + request, + // MESSAGE_INDEX = 5 + shell_scrubbed_input, + ) + ADD_TARGET_TO_STRUCT(data, target, language, local, message_range) + cached_voices[identifier] = data + if(length(in_process_tts_messages) < max_concurrent_requests) + request.begin_async() + in_process_tts_messages += list(data) + else + queued_tts_messages.insert(list(data)) + +#undef ADD_TARGET_TO_STRUCT + +#undef TARGET_INDEX +#undef IDENTIFIER_INDEX +#undef START_TIME_INDEX +#undef REQUEST_INDEX +#undef MESSAGE_INDEX diff --git a/code/datums/components/sign_language.dm b/code/datums/components/sign_language.dm index 9891c388dbe..bb6548ddf41 100644 --- a/code/datums/components/sign_language.dm +++ b/code/datums/components/sign_language.dm @@ -227,7 +227,9 @@ SIGNAL_HANDLER if(check_signables_state() == SIGN_ONE_HAND) - message_args[TREAT_MESSAGE_MESSAGE] = stars(message_args[TREAT_MESSAGE_MESSAGE]) + message_args[TREAT_MESSAGE_ARG] = stars(message_args[TREAT_MESSAGE_ARG]) + + message_args[TREAT_TTS_MESSAGE_ARG] = "" /// Signal proc for [COMSIG_MOVABLE_SAY_QUOTE] /// Removes exclamation/question marks. diff --git a/code/datums/quirks/negative_quirks.dm b/code/datums/quirks/negative_quirks.dm index 445a679e39c..f90dba0b290 100644 --- a/code/datums/quirks/negative_quirks.dm +++ b/code/datums/quirks/negative_quirks.dm @@ -694,8 +694,8 @@ if(prob(max(5,(nearby_people*12.5*moodmod)))) //Minimum 1/20 chance of stutter // Add a short stutter, THEN treat our word quirker.adjust_stutter(0.5 SECONDS) - new_message += quirker.treat_message(word, capitalize_message = FALSE) - + var/list/message_data = quirker.treat_message(word, capitalize_message = FALSE) + new_message += message_data["message"] else new_message += word diff --git a/code/datums/status_effects/debuffs/speech_debuffs.dm b/code/datums/status_effects/debuffs/speech_debuffs.dm index 7298696bfc1..e114e3143ec 100644 --- a/code/datums/status_effects/debuffs/speech_debuffs.dm +++ b/code/datums/status_effects/debuffs/speech_debuffs.dm @@ -3,6 +3,9 @@ alert_type = null remove_on_fullheal = TRUE + var/make_tts_message_original = FALSE + var/tts_filter = "" + /datum/status_effect/speech/on_creation(mob/living/new_owner, duration = 10 SECONDS) src.duration = duration return ..() @@ -23,10 +26,15 @@ /datum/status_effect/speech/proc/handle_message(datum/source, list/message_args) SIGNAL_HANDLER - var/phrase = html_decode(message_args[TREAT_MESSAGE_MESSAGE]) + var/phrase = html_decode(message_args[TREAT_MESSAGE_ARG]) if(!length(phrase)) return + if(length(tts_filter) > 0) + message_args[TREAT_TTS_FILTER_ARG] += tts_filter + if(make_tts_message_original) + message_args[TREAT_TTS_MESSAGE_ARG] = message_args[TREAT_MESSAGE_ARG] + var/final_phrase = "" var/original_char = "" @@ -35,7 +43,7 @@ final_phrase += apply_speech(original_char, original_char) - message_args[TREAT_MESSAGE_MESSAGE] = sanitize(final_phrase) + message_args[TREAT_MESSAGE_ARG] = sanitize(final_phrase) /** * Applies the speech effects on the past character, changing @@ -54,6 +62,9 @@ /// Regex of characters we won't apply a stutter to var/static/regex/no_stutter + make_tts_message_original = TRUE + tts_filter = "tremolo=f=10:d=0.8,rubberband=tempo=0.5" + /datum/status_effect/speech/stutter/on_creation(mob/living/new_owner, ...) . = ..() if(!.) @@ -83,7 +94,7 @@ /datum/status_effect/speech/stutter/derpspeech/handle_message(datum/source, list/message_args) - var/message = html_decode(message_args[TREAT_MESSAGE_MESSAGE]) + var/message = html_decode(message_args[TREAT_MESSAGE_ARG]) message = replacetext(message, " am ", " ") message = replacetext(message, " is ", " ") @@ -100,7 +111,7 @@ message = uppertext(message) message += "[apply_speech(exclamation, exclamation)]" - message_args[1] = message + message_args[TREAT_MESSAGE_ARG] = message var/mob/living/living_source = source if(!isliving(source) || living_source.has_status_effect(/datum/status_effect/speech/stutter)) @@ -207,6 +218,8 @@ doubletext_prob = 0 text_modification_file = "slurring_cult_text.json" + tts_filter = "rubberband=pitch=0.5,vibrato=5" + /datum/status_effect/speech/slurring/heretic id = "heretic_slurring" common_prob = 50 diff --git a/code/game/atoms_movable.dm b/code/game/atoms_movable.dm index 667b022b5c8..5bc5b4b6c58 100644 --- a/code/game/atoms_movable.dm +++ b/code/game/atoms_movable.dm @@ -94,6 +94,12 @@ /// The degree of pressure protection that mobs in list/contents have from the external environment, between 0 and 1 var/contents_pressure_protection = 0 + /// The voice that this movable makes when speaking + var/voice + + /// The filter to apply to the voice when processing the TTS audio message. + var/voice_filter = "" + /// Value used to increment ex_act() if reactionary_explosions is on /// How much we as a source block explosions by /// Will not automatically apply to the turf below you, you need to apply /datum/element/block_explosives in conjunction with this diff --git a/code/game/machinery/computer/communications.dm b/code/game/machinery/computer/communications.dm index 835053fcf80..5559ab440e7 100755 --- a/code/game/machinery/computer/communications.dm +++ b/code/game/machinery/computer/communications.dm @@ -718,7 +718,8 @@ return if(user.try_speak(input)) //Adds slurs and so on. Someone should make this use languages too. - input = user.treat_message(input) + var/list/input_data = user.treat_message(input) + input = input_data["message"] else //No cheating, mime/random mute guy! input = "..." diff --git a/code/game/say.dm b/code/game/say.dm index 918d97bd458..4fbc824f4de 100644 --- a/code/game/say.dm +++ b/code/game/say.dm @@ -74,12 +74,29 @@ GLOBAL_LIST_INIT(freqtospan, list( /atom/movable/proc/can_speak(allow_mimes = FALSE) return TRUE -/atom/movable/proc/send_speech(message, range = 7, obj/source = src, bubble_type, list/spans, datum/language/message_language, list/message_mods = list(), forced = FALSE) +/atom/movable/proc/send_speech(message, range = 7, obj/source = src, bubble_type, list/spans, datum/language/message_language, list/message_mods = list(), forced = FALSE, tts_message, list/tts_filter) + var/found_client = FALSE for(var/atom/movable/hearing_movable as anything in get_hearers_in_view(range, source)) if(!hearing_movable)//theoretically this should use as anything because it shouldnt be able to get nulls but there are reports that it does. stack_trace("somehow theres a null returned from get_hearers_in_view() in send_speech!") continue hearing_movable.Hear(null, src, message_language, message, null, spans, message_mods, range) + if(!found_client && length(hearing_movable.client_mobs_in_contents)) + found_client = TRUE + + var/tts_message_to_use = tts_message + if(!tts_message_to_use) + tts_message_to_use = message + + var/list/filter = list() + if(length(voice_filter) > 0) + filter += voice_filter + + if(length(tts_filter) > 0) + filter += tts_filter.Join(",") + + if(voice && found_client) + INVOKE_ASYNC(SStts, TYPE_PROC_REF(/datum/controller/subsystem/tts, queue_tts_message), src, html_decode(tts_message_to_use), message_language, voice, filter.Join(","), message_range = range) /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. diff --git a/code/modules/antagonists/changeling/changeling.dm b/code/modules/antagonists/changeling/changeling.dm index 4b0f9263038..1eee43d620f 100644 --- a/code/modules/antagonists/changeling/changeling.dm +++ b/code/modules/antagonists/changeling/changeling.dm @@ -571,6 +571,9 @@ new_profile.worn_icon_state_list[slot] = clothing_item.worn_icon_state new_profile.exists_list[slot] = 1 + new_profile.voice = target.voice + new_profile.voice_filter = target.voice_filter + return new_profile /* @@ -759,6 +762,8 @@ user.physique = chosen_profile.physique user.grad_style = LAZYLISTDUPLICATE(chosen_profile.grad_style) user.grad_color = LAZYLISTDUPLICATE(chosen_profile.grad_color) + user.voice = chosen_profile.voice + user.voice_filter = chosen_profile.voice_filter chosen_dna.transfer_identity(user, TRUE) @@ -908,6 +913,10 @@ var/list/grad_style = list("None", "None") /// The hair and facial hair gradient colours of the profile source. var/list/grad_color = list(null, null) + /// The TTS voice of the profile source + var/voice + /// The TTS filter of the profile filter + var/voice_filter = "" /datum/changeling_profile/Destroy() qdel(dna) @@ -946,6 +955,8 @@ new_profile.quirks = quirks.Copy() new_profile.grad_style = LAZYLISTDUPLICATE(grad_style) new_profile.grad_color = LAZYLISTDUPLICATE(grad_color) + new_profile.voice = voice + new_profile.voice_filter = voice_filter /datum/antagonist/changeling/roundend_report() var/list/parts = list() diff --git a/code/modules/client/preferences/middleware/tts.dm b/code/modules/client/preferences/middleware/tts.dm new file mode 100644 index 00000000000..ddb1ffadaeb --- /dev/null +++ b/code/modules/client/preferences/middleware/tts.dm @@ -0,0 +1,16 @@ +/// Middleware to handle quirks +/datum/preference_middleware/tts + /// Cooldown on requesting a TTS preview. + COOLDOWN_DECLARE(tts_test_cooldown) + + action_delegations = list( + "play_voice" = PROC_REF(play_voice), + ) + +/datum/preference_middleware/tts/proc/play_voice(list/params, mob/user) + if(!COOLDOWN_FINISHED(src, tts_test_cooldown)) + return TRUE + var/speaker = preferences.read_preference(/datum/preference/choiced/voice) + COOLDOWN_START(src, tts_test_cooldown, 0.5 SECONDS) + INVOKE_ASYNC(SStts, TYPE_PROC_REF(/datum/controller/subsystem/tts, queue_tts_message), user.client, "Hello, this is my voice.", speaker = speaker, local = TRUE) + return TRUE diff --git a/code/modules/client/preferences/sounds.dm b/code/modules/client/preferences/sounds.dm index a826f9ef19f..4e26a8c0a44 100644 --- a/code/modules/client/preferences/sounds.dm +++ b/code/modules/client/preferences/sounds.dm @@ -28,6 +28,11 @@ savefile_key = "sound_instruments" savefile_identifier = PREFERENCE_PLAYER +/datum/preference/toggle/sound_tts + category = PREFERENCE_CATEGORY_GAME_PREFERENCES + savefile_key = "sound_tts" + savefile_identifier = PREFERENCE_PLAYER + /// Controls hearing dance machines /datum/preference/toggle/sound_jukebox category = PREFERENCE_CATEGORY_GAME_PREFERENCES diff --git a/code/modules/client/preferences/voice.dm b/code/modules/client/preferences/voice.dm new file mode 100644 index 00000000000..9b9456ea9c4 --- /dev/null +++ b/code/modules/client/preferences/voice.dm @@ -0,0 +1,26 @@ +/// TTS voice preference +/datum/preference/choiced/voice + savefile_identifier = PREFERENCE_CHARACTER + savefile_key = "tts_voice" + category = PREFERENCE_CATEGORY_NON_CONTEXTUAL + +/datum/preference/choiced/voice/is_accessible(datum/preferences/preferences) + if(!SStts.tts_enabled) + return FALSE + return ..() + +/datum/preference/choiced/voice/init_possible_values() + if(SStts.tts_enabled) + return SStts.available_speakers + if(fexists("data/cached_tts_voices.json")) + var/list/text_data = rustg_file_read("data/cached_tts_voices.json") + var/list/cached_data = json_decode(text_data) + if(!cached_data) + return list("invalid") + return cached_data + return list("invalid") + +/datum/preference/choiced/voice/apply_to_human(mob/living/carbon/human/target, value) + if(SStts.tts_enabled && !(value in SStts.available_speakers)) + value = pick(SStts.available_speakers) // As a failsafe + target.voice = value diff --git a/code/modules/mob/living/brain/brain_say.dm b/code/modules/mob/living/brain/brain_say.dm index b7e1a9b5794..79e8e1d3079 100644 --- a/code/modules/mob/living/brain/brain_say.dm +++ b/code/modules/mob/living/brain/brain_say.dm @@ -19,7 +19,7 @@ else return ..() -/mob/living/brain/treat_message(message, capitalize_message = TRUE) +/mob/living/brain/treat_message(message, tts_message, tts_filter, capitalize_message = TRUE) if(capitalize_message) message = capitalize(message) - return message + return list(message = message, tts_message = message, tts_filter = list()) diff --git a/code/modules/mob/living/carbon/human/login.dm b/code/modules/mob/living/carbon/human/login.dm index dc2e5e70546..03795be168e 100644 --- a/code/modules/mob/living/carbon/human/login.dm +++ b/code/modules/mob/living/carbon/human/login.dm @@ -3,6 +3,9 @@ dna?.species?.on_owner_login(src) + if(SStts.tts_enabled && !voice) + voice = pick(SStts.available_speakers) + if(!LAZYLEN(afk_thefts)) return diff --git a/code/modules/mob/living/living_say.dm b/code/modules/mob/living/living_say.dm index cd128de28f2..0c4ab258d81 100644 --- a/code/modules/mob/living/living_say.dm +++ b/code/modules/mob/living/living_say.dm @@ -179,7 +179,10 @@ GLOBAL_LIST_INIT(message_modes_stat_limits, list( else log_talk(message, LOG_SAY, forced_by = forced, custom_say_emote = message_mods[MODE_CUSTOM_SAY_EMOTE]) - message = treat_message(message) // unfortunately we still need this + var/list/message_data = treat_message(message) // unfortunately we still need this + message = message_data["message"] + var/tts_message = message_data["tts_message"] + var/list/tts_filter = message_data["tts_filter"] spans |= speech_span @@ -200,10 +203,14 @@ GLOBAL_LIST_INIT(message_modes_stat_limits, list( if(!HAS_TRAIT(src, TRAIT_SIGN_LANG)) // if using sign language skip sending the say signal // Make sure the arglist is passed exactly - don't pass a copy of it. Say signal handlers will modify some of the parameters. + var/last_message = message var/sigreturn = SEND_SIGNAL(src, COMSIG_MOB_SAY, args) + if(last_message != message) + tts_message = message if(sigreturn & COMPONENT_UPPERCASE_SPEECH) message = uppertext(message) + if(!message) if(succumbed) succumb() @@ -238,8 +245,7 @@ GLOBAL_LIST_INIT(message_modes_stat_limits, list( if(pressure < ONE_ATMOSPHERE*0.4) //Thin air, let's italicise the message spans |= SPAN_ITALICS - send_speech(message, message_range, src, bubble_type, spans, language, message_mods)//roughly 58% of living/say()'s total cost - + send_speech(message, message_range, src, bubble_type, spans, language, message_mods, tts_message = tts_message, tts_filter = tts_filter)//roughly 58% of living/say()'s total cost if(succumbed) succumb(TRUE) to_chat(src, compose_message(src, language, message, , spans, message_mods)) @@ -321,7 +327,7 @@ GLOBAL_LIST_INIT(message_modes_stat_limits, list( show_message(message, MSG_AUDIBLE, deaf_message, deaf_type, avoid_highlight) return message -/mob/living/send_speech(message_raw, message_range = 6, obj/source = src, bubble_type = bubble_icon, list/spans, datum/language/message_language = null, list/message_mods = list(), forced = null) +/mob/living/send_speech(message_raw, message_range = 6, obj/source = src, bubble_type = bubble_icon, list/spans, datum/language/message_language = null, list/message_mods = list(), forced = null, tts_message, list/tts_filter) var/whisper_range = 0 var/is_speaker_whispering = FALSE if(message_mods[WHISPER_MODE]) //If we're whispering @@ -360,10 +366,27 @@ GLOBAL_LIST_INIT(message_modes_stat_limits, list( //speech bubble var/list/speech_bubble_recipients = list() + var/found_client = FALSE var/talk_icon_state = say_test(message_raw) for(var/mob/M in listening) - if(M.client && (!M.client.prefs.read_preference(/datum/preference/toggle/enable_runechat) || (SSlag_switch.measures[DISABLE_RUNECHAT] && !HAS_TRAIT(src, TRAIT_BYPASS_MEASURES)))) - speech_bubble_recipients.Add(M.client) + if(M.client) + if(!M.client.prefs.read_preference(/datum/preference/toggle/enable_runechat) || (SSlag_switch.measures[DISABLE_RUNECHAT] && !HAS_TRAIT(src, TRAIT_BYPASS_MEASURES))) + speech_bubble_recipients.Add(M.client) + found_client = TRUE + + if(voice && found_client && !message_mods[MODE_CUSTOM_SAY_ERASE_INPUT] && !HAS_TRAIT(src, TRAIT_SIGN_LANG)) + var/tts_message_to_use = tts_message + if(!tts_message_to_use) + tts_message_to_use = message_raw + + var/list/filter = list() + if(length(voice_filter) > 0) + filter += voice_filter + + if(length(tts_filter) > 0) + filter += tts_filter.Join(",") + + INVOKE_ASYNC(SStts, TYPE_PROC_REF(/datum/controller/subsystem/tts, queue_tts_message), src, html_decode(tts_message_to_use), message_language, voice, filter.Join(","), message_range = message_range) var/image/say_popup = image('icons/mob/effects/talk.dmi', src, "[bubble_type][talk_icon_state]", FLY_LAYER) SET_PLANE_EXPLICIT(say_popup, ABOVE_GAME_PLANE, src) @@ -416,16 +439,25 @@ GLOBAL_LIST_INIT(message_modes_stat_limits, list( * message - The message to treat. * capitalize_message - Whether we run capitalize() on the message after we're done. */ -/mob/living/proc/treat_message(message, capitalize_message = TRUE) +/mob/living/proc/treat_message(message, tts_message, tts_filter, capitalize_message = TRUE) if(HAS_TRAIT(src, TRAIT_UNINTELLIGIBLE_SPEECH)) message = unintelligize(message) - SEND_SIGNAL(src, COMSIG_LIVING_TREAT_MESSAGE, args) + tts_filter = list() + var/list/data = list(message, tts_message, tts_filter) + SEND_SIGNAL(src, COMSIG_LIVING_TREAT_MESSAGE, data) + message = data[TREAT_MESSAGE_ARG] + tts_message = data[TREAT_TTS_MESSAGE_ARG] + tts_filter = data[TREAT_TTS_FILTER_ARG] + + if(!tts_message) + tts_message = message if(capitalize_message) message = capitalize(message) + tts_message = capitalize(tts_message) - return message + return list(message = message, tts_message = tts_message, tts_filter = tts_filter) /mob/living/proc/radio(message, list/message_mods = list(), list/spans, language) var/obj/item/implant/radio/imp = locate() in src diff --git a/code/modules/mob/living/silicon/silicon.dm b/code/modules/mob/living/silicon/silicon.dm index 69859f0094b..9da64ed6542 100644 --- a/code/modules/mob/living/silicon/silicon.dm +++ b/code/modules/mob/living/silicon/silicon.dm @@ -13,6 +13,7 @@ flags_1 = PREVENT_CONTENTS_EXPLOSION_1 examine_cursor_icon = null fire_stack_decay_rate = -0.55 + voice_filter = "afftfilt=real='hypot(re,im)*sin(0)':imag='hypot(re,im)*cos(0)':win_size=512:overlap=1,rubberband=pitch=0.8" var/datum/ai_laws/laws = null//Now... THEY ALL CAN ALL HAVE LAWS var/last_lawchange_announce = 0 var/list/alarms_to_show = list() @@ -51,8 +52,11 @@ var/obj/item/modular_computer/pda/silicon/modularInterface + /mob/living/silicon/Initialize(mapload) . = ..() + if(SStts.tts_enabled) + voice = pick(SStts.available_speakers) GLOB.silicon_mobs += src faction += FACTION_SILICON if(ispath(radio)) diff --git a/code/modules/mob/living/simple_animal/friendly/robot_customer.dm b/code/modules/mob/living/simple_animal/friendly/robot_customer.dm index baa09c0eb86..13eac2939f3 100644 --- a/code/modules/mob/living/simple_animal/friendly/robot_customer.dm +++ b/code/modules/mob/living/simple_animal/friendly/robot_customer.dm @@ -82,7 +82,7 @@ if(bonus_overlays) . += bonus_overlays -/mob/living/simple_animal/robot_customer/send_speech(message, message_range, obj/source, bubble_type, list/spans, datum/language/message_language, list/message_mods, forced) +/mob/living/simple_animal/robot_customer/send_speech(message, message_range, obj/source, bubble_type, list/spans, datum/language/message_language, list/message_mods, forced, tts_message, list/tts_filter) . = ..() var/datum/customer_data/customer_info = ai_controller.blackboard[BB_CUSTOMER_CUSTOMERINFO] playsound(src, customer_info.speech_sound, 30, extrarange = MEDIUM_RANGE_SOUND_EXTRARANGE, falloff_distance = 5) diff --git a/code/modules/mob/living/simple_animal/parrot.dm b/code/modules/mob/living/simple_animal/parrot.dm index 574b0f760f3..e8c2b896436 100644 --- a/code/modules/mob/living/simple_animal/parrot.dm +++ b/code/modules/mob/living/simple_animal/parrot.dm @@ -898,13 +898,18 @@ GLOBAL_LIST_INIT(strippable_parrot_items, create_strippable_list(list( speak = list("Poly wanna cracker!", ":e Check the crystal, you chucklefucks!",":e Wire the solars, you lazy bums!",":e WHO TOOK THE DAMN MODSUITS?",":e OH GOD ITS ABOUT TO DELAMINATE CALL THE SHUTTLE") gold_core_spawnable = NO_SPAWN speak_chance = 3 + voice_filter = "rubberband=pitch=1.5" + var/memory_saved = FALSE var/rounds_survived = 0 var/longest_survival = 0 var/longest_deathstreak = 0 + /mob/living/simple_animal/parrot/poly/Initialize(mapload) ears = new /obj/item/radio/headset/headset_eng(src) + if(SStts.tts_enabled) + voice = pick(SStts.available_speakers) available_channels = list(":e") Read_Memory() if(rounds_survived == longest_survival) diff --git a/code/modules/mob/login.dm b/code/modules/mob/login.dm index 55afd32a737..4f8b3611b8e 100644 --- a/code/modules/mob/login.dm +++ b/code/modules/mob/login.dm @@ -29,6 +29,7 @@ /mob/Login() if(!client) return FALSE + canon_client = client add_to_player_list() lastKnownIP = client.address diff --git a/code/modules/vending/_vending.dm b/code/modules/vending/_vending.dm index e9eef8196e3..eb6d894ebb2 100644 --- a/code/modules/vending/_vending.dm +++ b/code/modules/vending/_vending.dm @@ -64,6 +64,7 @@ payment_department = ACCOUNT_SRV light_power = 0.7 light_range = MINIMUM_USEFUL_LIGHT_RANGE + voice_filter = "aderivative" /// Is the machine active (No sales pitches if off)! var/active = 1 @@ -185,7 +186,6 @@ /// used for narcing on underages var/obj/item/radio/sec_radio - /** * Initialize the vending machine * @@ -208,6 +208,12 @@ . = ..() wires = new /datum/wires/vending(src) + if(SStts.tts_enabled) + var/static/vendor_voice_by_type = list() + if(!vendor_voice_by_type[type]) + vendor_voice_by_type[type] = pick(SStts.available_speakers) + voice = vendor_voice_by_type[type] + if(build_inv) //non-constructable vending machine build_inventories() diff --git a/config/config.txt b/config/config.txt index d5b3168d425..cfb9391ae96 100644 --- a/config/config.txt +++ b/config/config.txt @@ -652,6 +652,15 @@ PR_ANNOUNCEMENTS_PER_ROUND 5 ## Uncomment to block granting profiling privileges to users with R_DEBUG, for performance purposes #FORBID_ADMIN_PROFILING +## Link to a HTTP server that's been set up on a server. Docker-compose file can be found in tools/tts +#TTS_HTTP_URL http://localhost:5002 + +## Token that can be used to prevent misuse of your TTS server that you've set up. +#TTS_HTTP_TOKEN coolio + +## The maximum number of concurrent tts http requests that can be made by the server at once. +#TTS_MAX_CONCURRENT_REQUESTS 4 + ## 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 diff --git a/interface/skin.dmf b/interface/skin.dmf index ae43a3c401b..d6f5313551c 100644 --- a/interface/skin.dmf +++ b/interface/skin.dmf @@ -86,6 +86,14 @@ window "mainwindow" anchor2 = -1,-1 is-visible = false saved-params = "" + elem "html_audio_player" + type = BROWSER + pos = 0,0 + size = 200x200 + anchor1 = none + anchor2 = none + is-visible = false + saved-params = "" elem "tooltip" type = BROWSER pos = 0,0 diff --git a/tgstation.dme b/tgstation.dme index d384779c539..c30d94b9206 100644 --- a/tgstation.dme +++ b/tgstation.dme @@ -409,6 +409,7 @@ #include "code\__HELPERS\text.dm" #include "code\__HELPERS\time.dm" #include "code\__HELPERS\traits.dm" +#include "code\__HELPERS\tts.dm" #include "code\__HELPERS\turfs.dm" #include "code\__HELPERS\type2type.dm" #include "code\__HELPERS\type_processing.dm" @@ -614,6 +615,7 @@ #include "code\controllers\subsystem\timer.dm" #include "code\controllers\subsystem\title.dm" #include "code\controllers\subsystem\traitor.dm" +#include "code\controllers\subsystem\tts.dm" #include "code\controllers\subsystem\tutorials.dm" #include "code\controllers\subsystem\verb_manager.dm" #include "code\controllers\subsystem\vis_overlays.dm" @@ -3067,6 +3069,7 @@ #include "code\modules\client\preferences\ui_style.dm" #include "code\modules\client\preferences\underwear_color.dm" #include "code\modules\client\preferences\uplink_location.dm" +#include "code\modules\client\preferences\voice.dm" #include "code\modules\client\preferences\widescreen.dm" #include "code\modules\client\preferences\window_flashing.dm" #include "code\modules\client\preferences\middleware\_middleware.dm" @@ -3078,6 +3081,7 @@ #include "code\modules\client\preferences\middleware\quirks.dm" #include "code\modules\client\preferences\middleware\random.dm" #include "code\modules\client\preferences\middleware\species.dm" +#include "code\modules\client\preferences\middleware\tts.dm" #include "code\modules\client\preferences\migrations\body_type_migration.dm" #include "code\modules\client\preferences\migrations\convert_to_json_savefile.dm" #include "code\modules\client\preferences\migrations\legacy_sound_toggles_migration.dm" 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 00000000000..7c87dbffc77 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/character_preferences/tts_voice.tsx @@ -0,0 +1,29 @@ +import { FeatureChoiced, FeatureChoicedServerData, FeatureDropdownInput, FeatureValueProps } from '../base'; +import { Stack, Button } from '../../../../../components'; + +const FeatureTTSDropdownInput = ( + props: FeatureValueProps +) => { + return ( + + + + + +