From 898ea17444ab7644e891e565c4960e422413862e Mon Sep 17 00:00:00 2001 From: Watermelon914 <3052169-Watermelon914@users.noreply.gitlab.com> Date: Mon, 2 Sep 2024 16:29:48 +0100 Subject: [PATCH] TTS integration --- code/__DEFINES/tts.dm | 11 + code/__HELPERS/heap.dm | 80 ++++ code/__HELPERS/tts.dm | 4 + .../configuration/entries/game_options.dm | 12 + code/controllers/subsystem/tts.dm | 413 ++++++++++++++++++ code/game/sound.dm | 40 +- code/modules/client/preferences.dm | 32 +- code/modules/mob/mob_defines.dm | 8 + colonialmarines.dme | 2 + 9 files changed, 569 insertions(+), 33 deletions(-) create mode 100644 code/__DEFINES/tts.dm create mode 100644 code/__HELPERS/heap.dm create mode 100644 code/__HELPERS/tts.dm create mode 100644 code/controllers/subsystem/tts.dm diff --git a/code/__DEFINES/tts.dm b/code/__DEFINES/tts.dm new file mode 100644 index 000000000000..7b5bdc24d562 --- /dev/null +++ b/code/__DEFINES/tts.dm @@ -0,0 +1,11 @@ +///TTS preference is disbaled entirely, no sound will be played. +#define TTS_SOUND_OFF "Disabled" +///TTS preference is enabled, and will give full text-to-speech. +#define TTS_SOUND_ENABLED "Enabled" +///TTS preference is set to only play blips of a sound, rather than speech. +#define TTS_SOUND_BLIPS "Blips Only" + +///TTS filter to activate start/stop radio clicks on speech. +#define TTS_FILTER_RADIO "radio" +///TTS filter to activate a silicon effect on speech. +#define TTS_FILTER_SILICON "silicon" diff --git a/code/__HELPERS/heap.dm b/code/__HELPERS/heap.dm new file mode 100644 index 000000000000..eeabfa6a20b4 --- /dev/null +++ b/code/__HELPERS/heap.dm @@ -0,0 +1,80 @@ +////////////////////// +//datum/heap object +////////////////////// + +/datum/heap + var/list/L + var/cmp + +/datum/heap/New(compare) + L = new() + cmp = compare + +/datum/heap/Destroy(force) + for(var/i in L) // because this is before the list helpers are loaded + qdel(i) + L = null + return ..() + +/datum/heap/proc/is_empty() + return !length(L) + +//insert and place at its position a new node in the heap +/datum/heap/proc/insert(A) + + L.Add(A) + swim(length(L)) + +//removes and returns the first element of the heap +//(i.e the max or the min dependant on the comparison function) +/datum/heap/proc/pop() + if(!length(L)) + return 0 + . = L[1] + + L[1] = L[length(L)] + L.Cut(length(L)) + if(length(L)) + sink(1) + +//Get a node up to its right position in the heap +/datum/heap/proc/swim(index) + var/parent = round(index * 0.5) + + while(parent > 0 && (call(cmp)(L[index],L[parent]) > 0)) + L.Swap(index,parent) + index = parent + parent = round(index * 0.5) + +//Get a node down to its right position in the heap +/datum/heap/proc/sink(index) + var/g_child = get_greater_child(index) + + while(g_child > 0 && (call(cmp)(L[index],L[g_child]) < 0)) + L.Swap(index,g_child) + index = g_child + g_child = get_greater_child(index) + +//Returns the greater (relative to the comparison proc) of a node children +//or 0 if there's no child +/datum/heap/proc/get_greater_child(index) + if(index * 2 > length(L)) + return 0 + + if(index * 2 + 1 > length(L)) + return index * 2 + + if(call(cmp)(L[index * 2],L[index * 2 + 1]) < 0) + return index * 2 + 1 + else + return index * 2 + +//Replaces a given node so it verify the heap condition +/datum/heap/proc/resort(A) + var/index = L.Find(A) + + swim(index) + sink(index) + +/datum/heap/proc/List() + . = L.Copy() diff --git a/code/__HELPERS/tts.dm b/code/__HELPERS/tts.dm new file mode 100644 index 000000000000..9d96d3d4e3e2 --- /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 741862b5d65d..eef25954f6ab 100644 --- a/code/controllers/configuration/entries/game_options.dm +++ b/code/controllers/configuration/entries/game_options.dm @@ -22,6 +22,18 @@ /datum/config_entry/flag/emojis +/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/str_list/tts_voice_blacklist + /datum/config_entry/string/alert_delta config_entry_value = "Destruction of the station is imminent. All crew are instructed to obey all instructions given by heads of staff. Any violations of these orders can be punished by death. This is not a drill." diff --git a/code/controllers/subsystem/tts.dm b/code/controllers/subsystem/tts.dm new file mode 100644 index 000000000000..178ac701dae9 --- /dev/null +++ b/code/controllers/subsystem/tts.dm @@ -0,0 +1,413 @@ +SUBSYSTEM_DEF(tts) + name = "Text To Speech" + wait = 0.05 SECONDS + priority = SS_PRIORITY_TTS + init_order = SS_INIT_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. + var/datum/heap/queued_http_messages + + /// An associative list of mobs mapped to a list of their own /datum/tts_request_target + var/list/queued_tts_messages = list() + + /// TTS audio files that are being processed on when to be played. + var/list/current_processing_tts_messages = list() + + /// HTTP requests currently in progress but not being processed yet + var/list/in_process_http_messages = list() + + /// HTTP requests that are being processed to see if they've been finished + var/list/current_processing_http_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() + + /// Whether TTS is enabled or not + var/tts_enabled = FALSE + /// Whether the TTS engine supports pitch adjustment or not. + var/pitch_enabled = FALSE + + /// TTS messages won't play if requests took longer than this duration of time. + var/message_timeout = 7 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/stat_entry(msg) + msg = "Active:[length(in_process_http_messages)]|Standby:[length(queued_http_messages?.L)]|Avg:[average_tts_messages_time]" + return ..() + +/proc/cmp_word_length_asc(datum/tts_request/a, datum/tts_request/b) + return length(b.message) - length(a.message) + +/// Establishes (or re-establishes) a connection to the TTS server and updates the list of available speakers. +/// This is blocking, so be careful when calling. +/datum/controller/subsystem/tts/proc/establish_connection_to_tts() + 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 FALSE + available_speakers = json_decode(response.body) + tts_enabled = TRUE + if(CONFIG_GET(str_list/tts_voice_blacklist)) + var/list/blacklisted_voices = CONFIG_GET(str_list/tts_voice_blacklist) + log_config("Processing the TTS voice blacklist.") + for(var/voice in blacklisted_voices) + if(available_speakers.Find(voice)) + log_config("Removed speaker [voice] from the TTS voice pool per config.") + available_speakers.Remove(voice) + var/datum/http_request/request_pitch = new() + var/list/headers_pitch = list() + headers_pitch["Authorization"] = CONFIG_GET(string/tts_http_token) + request_pitch.prepare(RUSTG_HTTP_METHOD_GET, "[CONFIG_GET(string/tts_http_url)]/pitch-available", "", headers_pitch) + request_pitch.begin_async() + UNTIL(request_pitch.is_complete()) + pitch_enabled = TRUE + var/datum/http_response/response_pitch = request_pitch.into_response() + if(response_pitch.errored || response_pitch.status_code != 200) + if(response_pitch.errored) + stack_trace(response.error) + pitch_enabled = FALSE + 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 TRUE + +/datum/controller/subsystem/tts/Initialize() + if(!CONFIG_GET(string/tts_http_url)) + return + + queued_http_messages = new /datum/heap(/proc/cmp_word_length_asc)) + max_concurrent_requests = CONFIG_GET(number/tts_max_concurrent_requests) + if(!establish_connection_to_tts()) + var/msg = "Failed to initialize [name] subsystem within [time] second[time == 1 ? "" : "s"]!" + to_chat(world, "[msg]") + return + return ..() + +/datum/controller/subsystem/tts/proc/play_tts(target, list/listeners, sound/audio, sound/audio_blips, datum/language/language, range = 7, volume_offset = 0) + var/turf/turf_source = get_turf(target) + if(!turf_source) + return + + var/channel = get_free_channel() + for(var/mob/listening_mob in listeners | SSmobs.dead_players_by_zlevel[turf_source.z])//observers always hear through walls + if(QDELING(listening_mob)) + stack_trace("TTS tried to play a sound to a deleted mob.") + continue + var/volume_to_play_at = listening_mob.client?.prefs.read_preference(/datum/preference/numeric/sound_tts_volume) + var/tts_pref = listening_mob.client?.prefs.read_preference(/datum/preference/choiced/sound_tts) + if(volume_to_play_at == 0 || (tts_pref == TTS_SOUND_OFF)) + continue + var/sound_volume = ((listening_mob == target)? 60 : 85) + volume_offset + sound_volume = sound_volume * (volume_to_play_at / 100) + var/audio_to_use = (tts_pref == TTS_SOUND_BLIPS) ? audio_blips : audio + if(!listening_mob.say_understands(language)) + continue + + if(get_dist(listening_mob, turf_source) <= range) + var/datum/sound_template/template = get_sound_template(audio_to_use, turf_source, volume_to_play_at, ) + listening_mob.playsound_local( + turf_source, + vol = sound_volume, + falloff_exponent = SOUND_FALLOFF_EXPONENT, + channel = channel, + pressure_affected = TRUE, + sound_to_use = audio_to_use, + max_distance = SOUND_RANGE, + falloff_distance = SOUND_DEFAULT_FALLOFF_DISTANCE, + distance_multiplier = 1, + use_reverb = TRUE + ) + +// 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/datum/tts_request/data in in_process_http_messages) + var/datum/http_request/request = data.request + var/datum/http_request/request_blips = data.request_blips + UNTIL(request.is_complete() && request_blips.is_complete()) + +#define SHIFT_DATA_ARRAY(tts_message_queue, target, data) \ + popleft(##data); \ + if(length(##data) == 0) { \ + ##tts_message_queue -= ##target; \ + }; + +#define TTS_ARBRITRARY_DELAY "arbritrary delay" + +/datum/controller/subsystem/tts/fire(resumed) + if(!tts_enabled) + flags |= SS_NO_FIRE + return + + if(!resumed) + while(length(in_process_http_messages) < max_concurrent_requests && length(queued_http_messages.L) > 0) + var/datum/tts_request/entry = queued_http_messages.pop() + var/timeout = entry.start_time + message_timeout + if(timeout < world.time) + entry.timed_out = TRUE + continue + entry.start_requests() + in_process_http_messages += entry + current_processing_http_messages = in_process_http_messages.Copy() + current_processing_tts_messages = queued_tts_messages.Copy() + + // For speed + var/list/processing_messages = current_processing_http_messages + while(processing_messages.len) + var/datum/tts_request/current_request = processing_messages[processing_messages.len] + processing_messages.len-- + if(!current_request.requests_completed()) + continue + + var/datum/http_response/response = current_request.get_primary_response() + in_process_http_messages -= current_request + average_tts_messages_time = MC_AVERAGE(average_tts_messages_time, world.time - current_request.start_time) + var/identifier = current_request.identifier + if(current_request.requests_errored()) + current_request.timed_out = TRUE + continue + current_request.audio_length = text2num(response.headers["audio-length"]) * 10 + if(!current_request.audio_length) + current_request.audio_length = 0 + current_request.audio_file = "tmp/tts/[identifier].ogg" + current_request.audio_file_blips = "tmp/tts/[identifier]_blips.ogg" // We aren't as concerned about the audio length for blips as we are with actual speech + // Don't need the request anymore so we can deallocate it + current_request.request = null + current_request.request_blips = null + if(MC_TICK_CHECK) + return + + var/list/processing_tts_messages = current_processing_tts_messages + while(processing_tts_messages.len) + if(MC_TICK_CHECK) + return + + var/datum/tts_target = processing_tts_messages[processing_tts_messages.len] + var/list/data = processing_tts_messages[tts_target] + processing_tts_messages.len-- + if(QDELETED(tts_target)) + queued_tts_messages -= tts_target + continue + + var/datum/tts_request/current_target = data[1] + // This determines when we start the timer to time out. + // This is so that the TTS message doesn't get timed out if it's waiting + // on another TTS message to finish playing their audio. + + // For example, if a TTS message plays for more than 7 seconds, which is our current timeout limit, + // then the next TTS message would be unable to play. + var/timeout_start = current_target.when_to_play + if(!timeout_start) + // In the normal case, we just set timeout to start_time as it means we aren't waiting on + // a TTS message to finish playing + timeout_start = current_target.start_time + + var/timeout = timeout_start + message_timeout + // Here, we check if the request has timed out or not. + // If current_target.timed_out is set to TRUE, it means the request failed in some way + // and there is no TTS audio file to play. + if(timeout < world.time || current_target.timed_out) + SHIFT_DATA_ARRAY(queued_tts_messages, tts_target, data) + continue + + if(current_target.audio_file) + if(current_target.audio_file == TTS_ARBRITRARY_DELAY) + if(current_target.when_to_play < world.time) + SHIFT_DATA_ARRAY(queued_tts_messages, tts_target, data) + continue + var/sound/audio_file + var/sound/audio_file_blips + if(current_target.local) + if(current_target.use_blips) + audio_file_blips = new(current_target.audio_file_blips) + SEND_SOUND(current_target.target, audio_file_blips) + else + audio_file = new(current_target.audio_file) + SEND_SOUND(current_target.target, audio_file) + SHIFT_DATA_ARRAY(queued_tts_messages, tts_target, data) + else if(current_target.when_to_play < world.time) + audio_file = new(current_target.audio_file) + audio_file_blips = new(current_target.audio_file_blips) + play_tts(tts_target, current_target.listeners, audio_file, audio_file_blips, current_target.language, current_target.message_range, current_target.volume_offset) + if(length(data) != 1) + var/datum/tts_request/next_target = data[2] + next_target.when_to_play = world.time + current_target.audio_length + else + // So that if the audio file is already playing whilst a new file comes in, + // it won't play in the middle of the audio file. + var/datum/tts_request/arbritrary_delay = new() + arbritrary_delay.when_to_play = world.time + current_target.audio_length + arbritrary_delay.audio_file = TTS_ARBRITRARY_DELAY + queued_tts_messages[tts_target] += arbritrary_delay + SHIFT_DATA_ARRAY(queued_tts_messages, tts_target, data) + + +#undef TTS_ARBRITRARY_DELAY + +/datum/controller/subsystem/tts/proc/queue_tts_message(datum/target, message, datum/language/language, speaker, filter, list/listeners, local = FALSE, message_range = 7, volume_offset = 0, pitch = 0, special_filters = "") + if(!tts_enabled) + return + + // 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") + + 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 + num2text(pitch) + special_filters + shell_scrubbed_input)].[world.time]" + 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/datum/http_request/request_blips = new() + var/file_name = "tmp/tts/[identifier].ogg" + var/file_name_blips = "tmp/tts/[identifier]_blips.ogg" + request.prepare(RUSTG_HTTP_METHOD_GET, "[CONFIG_GET(string/tts_http_url)]/tts?voice=[speaker]&identifier=[identifier]&filter=[url_encode(filter)]&pitch=[pitch]&special_filters=[url_encode(special_filters)]", json_encode(list("text" = shell_scrubbed_input)), headers, file_name) + request_blips.prepare(RUSTG_HTTP_METHOD_GET, "[CONFIG_GET(string/tts_http_url)]/tts-blips?voice=[speaker]&identifier=[identifier]&filter=[url_encode(filter)]&pitch=[pitch]&special_filters=[url_encode(special_filters)]", json_encode(list("text" = shell_scrubbed_input)), headers, file_name_blips) + var/datum/tts_request/current_request = new /datum/tts_request(identifier, request, request_blips, shell_scrubbed_input, target, local, language, message_range, volume_offset, listeners, pitch) + var/list/player_queued_tts_messages = queued_tts_messages[target] + if(!player_queued_tts_messages) + player_queued_tts_messages = list() + queued_tts_messages[target] = player_queued_tts_messages + player_queued_tts_messages += current_request + if(length(in_process_http_messages) < max_concurrent_requests) + current_request.start_requests() + in_process_http_messages += current_request + else + queued_http_messages.insert(current_request) + +/// A struct containing information on an individual player or mob who has made a TTS request +/datum/tts_request + /// The mob to play this TTS message on + var/mob/target + /// The people who are going to hear this TTS message + /// Does nothing if local is set to TRUE + var/list/listeners + /// The HTTP request of this message + var/datum/http_request/request + /// The HTTP request of this message for blips + var/datum/http_request/request_blips + /// The language to limit this TTS message to + var/datum/language/language + /// The message itself + var/message + /// The message identifier + var/identifier + /// The volume offset to play this TTS at. + var/volume_offset = 0 + /// Whether this TTS message should be sent to the target only or not. + var/local = FALSE + /// The message range to play this TTS message + var/message_range = 7 + /// The time at which this request was started + var/start_time + + /// The audio file of this tts request. + var/sound/audio_file + /// The blips audio file of this tts request. + var/sound/audio_file_blips + /// The audio length of this tts request. + var/audio_length + /// When the audio file should play at the minimum + var/when_to_play = 0 + /// Whether this request was timed out or not + var/timed_out = FALSE + /// Does this use blips during local generation or not? + var/use_blips = FALSE + /// What's the pitch adjustment? + var/pitch = 0 + + +/datum/tts_request/New(identifier, datum/http_request/request, datum/http_request/request_blips, message, target, local, datum/language/language, message_range, volume_offset, list/listeners, pitch) + . = ..() + src.identifier = identifier + src.request = request + src.request_blips = request_blips + src.message = message + src.language = language + src.target = target + src.local = local + src.message_range = message_range + src.volume_offset = volume_offset + src.listeners = listeners + src.pitch = pitch + start_time = world.time + +/datum/tts_request/proc/start_requests() + if(istype(target, /client)) + var/client/current_client = target + use_blips = (current_client?.prefs.read_preference(/datum/preference/choiced/sound_tts) == TTS_SOUND_BLIPS) + else if(istype(target, /mob)) + use_blips = (target.client?.prefs.read_preference(/datum/preference/choiced/sound_tts) == TTS_SOUND_BLIPS) + if(local) + if(use_blips) + request_blips.begin_async() + else + request.begin_async() + else + request.begin_async() + request_blips.begin_async() + +/datum/tts_request/proc/get_primary_request() + if(local) + if(use_blips) + return request_blips + else + return request + else + return request + +/datum/tts_request/proc/get_primary_response() + if(local) + if(use_blips) + return request_blips.into_response() + else + return request.into_response() + else + return request.into_response() + +/datum/tts_request/proc/requests_errored() + if(local) + var/datum/http_response/response + if(use_blips) + response = request_blips.into_response() + else + response = request.into_response() + return response.errored + else + var/datum/http_response/response = request.into_response() + var/datum/http_response/response_blips = request_blips.into_response() + return response.errored || response_blips.errored + +/datum/tts_request/proc/requests_completed() + if(local) + if(use_blips) + return request_blips.is_complete() + else + return request.is_complete() + else + return request.is_complete() && request_blips.is_complete() + +#undef SHIFT_DATA_ARRAY diff --git a/code/game/sound.dm b/code/game/sound.dm index fab66dc1a1a5..de54fd6a6de3 100644 --- a/code/game/sound.dm +++ b/code/game/sound.dm @@ -65,6 +65,13 @@ template.frequency = vary else template.frequency = GET_RANDOM_FREQ // Same frequency for everybody + return template + +/proc/playsound(atom/source, soundin, vol = 100, vary = FALSE, sound_range, vol_cat = VOLUME_SFX, channel = 0, status, falloff = 1, list/echo, y_s_offset, x_s_offset) + if(isarea(source)) + error("[source] is an area and is trying to make the sound: [soundin]") + return FALSE + var/datum/sound_template/S = get_sound_template(soundin, vol, vary, vol_cat, channel, status, falloff, echo, y_s_offset, x_s_offset) if(!sound_range) sound_range = floor(0.25*vol) //if no specific range, the max range is equal to a quarter of the volume. @@ -101,40 +108,9 @@ SSsound.queue(template, null, extra_interiors) return template.channel - - //This is the replacement for playsound_local. Use this for sending sounds directly to a client /proc/playsound_client(client/client, sound/soundin, atom/origin, vol = 100, random_freq, vol_cat = VOLUME_SFX, channel = 0, status, list/echo, y_s_offset, x_s_offset) - if(!istype(client) || !client.soundOutput) - return FALSE - - var/datum/sound_template/template = new() - if(origin) - var/turf/T = get_turf(origin) - if(T) - template.x = T.x - template.y = T.y - template.z = T.z - if(istype(soundin)) - template.file = soundin.file - template.wait = soundin.wait - template.repeat = soundin.repeat - else - template.file = get_sfx(soundin) - - if(random_freq) - template.frequency = GET_RANDOM_FREQ - template.volume = vol - template.volume_cat = vol_cat - template.channel = channel - template.status = status - for(var/pos = 1 to length(echo)) - if(!echo[pos]) - continue - template.echo[pos] = echo[pos] - template.y_s_offset = y_s_offset - template.x_s_offset = x_s_offset - SSsound.queue(template, list(client)) + SSsound.queue(get_sound_template(soundin, origin, vol, random_freq, vol_cat, channel, status, echo, y_s_offset, x_s_offset), list(C)) /// Plays sound to all mobs that are map-level contents of an area /proc/playsound_area(area/A, soundin, vol = 100, channel = 0, status, vol_cat = VOLUME_SFX, list/echo, y_s_offset, x_s_offset) diff --git a/code/modules/client/preferences.dm b/code/modules/client/preferences.dm index dd0d406560ab..910ebe8e9d8c 100644 --- a/code/modules/client/preferences.dm +++ b/code/modules/client/preferences.dm @@ -204,6 +204,15 @@ GLOBAL_LIST_INIT(bgstate_options, list( var/xeno_vision_level_pref = XENO_VISION_LEVEL_MID_NVG var/playtime_perks = TRUE + // TTS + var/voice = "" + var/voice_pitch = 0 + var/xeno_voice = "" + var/xeno_pitch = "" + var/synth_voice = "" + var/synth_pitch = 0 + var/tts_mode = TTS_SOUND_ENABLED + var/stylesheet = "Modern" var/lang_chat_disabled = FALSE @@ -337,6 +346,8 @@ GLOBAL_LIST_INIT(bgstate_options, list( dat += "