diff --git a/code/controllers/subsystem/tts.dm b/code/controllers/subsystem/tts.dm index 1408ef9efed..20963706d25 100644 --- a/code/controllers/subsystem/tts.dm +++ b/code/controllers/subsystem/tts.dm @@ -1,9 +1,3 @@ -#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 @@ -12,34 +6,29 @@ SUBSYSTEM_DEF(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 + 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_tts_messages = list() + var/list/in_process_http_messages = list() /// HTTP requests that are being processed to see if they've been finished - var/list/current_processing_tts_messages = list() + 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() - /// 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 @@ -55,17 +44,15 @@ SUBSYSTEM_DEF(tts) 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]" + msg = "Active:[length(in_process_http_messages)]|Standby:[length(queued_http_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]) +/proc/cmp_word_length_asc(datum/tts_request/a, datum/tts_request/b) + return length(b.message) - length(a.message) -/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)) +/// 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) @@ -75,60 +62,70 @@ SUBSYSTEM_DEF(tts) 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) + return FALSE 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 TRUE + +/datum/controller/subsystem/tts/Initialize() + if(!CONFIG_GET(string/tts_http_url)) + return SS_INIT_NO_NEED + + queued_http_messages = new /datum/heap(GLOBAL_PROC_REF(cmp_word_length_asc)) + max_concurrent_requests = CONFIG_GET(number/tts_max_concurrent_requests) + if(!establish_connection_to_tts()) + return SS_INIT_FAILURE 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 - +/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 = 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)) + var/volume_to_play_at = listening_mob.client?.prefs.read_preference(/datum/preference/numeric/sound_tts_volume) + var/use_blips = listening_mob.client?.prefs.read_preference(/datum/preference/toggle/sound_tts_blips) + if(volume_to_play_at == 0 || !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)) + var/sound_volume = ((listening_mob == target)? 60 : 85) + volume_offset + sound_volume = sound_volume * (volume_to_play_at / 100) + var/datum/language_holder/holder = listening_mob.get_language_holder() + var/audio_to_use = use_blips ? audio_blips : audio + if(!holder.has_language(language, spoken = FALSE)) + continue + if(get_dist(listening_mob, turf_source) <= range) listening_mob.playsound_local( turf_source, - vol = (listening_mob == target)? 60 : 85, + vol = sound_volume, falloff_exponent = SOUND_FALLOFF_EXPONENT, channel = channel, pressure_affected = TRUE, - sound_to_use = audio, + sound_to_use = audio_to_use, 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()) + 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) @@ -136,42 +133,118 @@ SUBSYSTEM_DEF(tts) 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() + 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_tts_messages + var/list/processing_messages = current_processing_http_messages while(processing_messages.len) - var/current_message = processing_messages[processing_messages.len] + var/datum/tts_request/current_request = processing_messages[processing_messages.len] processing_messages.len-- - var/datum/http_request/request = current_message[REQUEST_INDEX] - if(!request.is_complete()) + if(!current_request.requests_completed()) 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) + 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 - - 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"]) + 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 -#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)) + var/list/processing_tts_messages = current_processing_tts_messages + while(processing_tts_messages.len) + if(MC_TICK_CHECK) + return -/datum/controller/subsystem/tts/proc/queue_tts_message(target, message, datum/language/language, speaker, filter, local = FALSE, message_range = 7) + 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) + 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) 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 @@ -180,15 +253,7 @@ SUBSYSTEM_DEF(tts) 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 + var/identifier = "[sha1(speaker + filter + shell_scrubbed_input)].[world.time]" if(!(speaker in available_speakers)) return @@ -196,33 +261,128 @@ SUBSYSTEM_DEF(tts) 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)]", 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) + request_blips.prepare(RUSTG_HTTP_METHOD_GET, "[CONFIG_GET(string/tts_http_url)]/tts-blips?voice=[speaker]&identifier=[identifier]&filter=[url_encode(filter)]", 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) + 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_tts_messages.insert(list(data)) + queued_http_messages.insert(current_request) -#undef ADD_TARGET_TO_STRUCT +/// 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 -#undef TARGET_INDEX -#undef IDENTIFIER_INDEX -#undef START_TIME_INDEX -#undef REQUEST_INDEX -#undef MESSAGE_INDEX + /// 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 + + +/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) + . = ..() + 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 + 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/toggle/sound_tts_blips) + else if(istype(target, /mob)) + use_blips = target.client?.prefs.read_preference(/datum/preference/toggle/sound_tts_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() diff --git a/code/game/say.dm b/code/game/say.dm index 4fbc824f4de..e54250e8134 100644 --- a/code/game/say.dm +++ b/code/game/say.dm @@ -33,8 +33,11 @@ GLOBAL_LIST_INIT(freqtospan, list( language = get_selected_language() send_speech(message, message_range, src, bubble_type, spans, message_language = language, forced = forced) +/// Called when this movable hears a message from a source. +/// Returns TRUE if the message was received and understood. /atom/movable/proc/Hear(message, atom/movable/speaker, message_language, raw_message, radio_freq, list/spans, list/message_mods = list(), message_range=0) SEND_SIGNAL(src, COMSIG_MOVABLE_HEAR, args) + return TRUE /** @@ -76,11 +79,14 @@ GLOBAL_LIST_INIT(freqtospan, list( /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)) + var/list/listeners = get_hearers_in_view(range, source) + var/list/listened = list() + for(var/atom/movable/hearing_movable as anything in listeners) 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(hearing_movable.Hear(null, src, message_language, message, null, spans, message_mods, range)) + listened += hearing_movable if(!found_client && length(hearing_movable.client_mobs_in_contents)) found_client = TRUE @@ -96,7 +102,7 @@ GLOBAL_LIST_INIT(freqtospan, list( 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) + 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(","), listened, 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/admin/admin_verbs.dm b/code/modules/admin/admin_verbs.dm index 873e7f49db4..f9a217bc083 100644 --- a/code/modules/admin/admin_verbs.dm +++ b/code/modules/admin/admin_verbs.dm @@ -209,6 +209,7 @@ GLOBAL_PROTECT(admin_verbs_debug) /client/proc/populate_world, /client/proc/pump_random_event, /client/proc/print_cards, + /client/proc/reestablish_tts_connection, /client/proc/reload_cards, /client/proc/reload_configuration, /client/proc/restart_controller, diff --git a/code/modules/admin/verbs/debug.dm b/code/modules/admin/verbs/debug.dm index 0989b19c5c2..b68db5cdeba 100644 --- a/code/modules/admin/verbs/debug.dm +++ b/code/modules/admin/verbs/debug.dm @@ -883,6 +883,25 @@ [second_queue] "}, "window=check_timer_sources;size=700x700") +/// A debug verb to try and re-establish a connection with the TTS server and to refetch TTS voices. +/// Since voices are cached beforehand, this is unlikely to update preferences. +/client/proc/reestablish_tts_connection() + set category = "Debug" + set name = "Re-establish Connection To TTS" + set desc = "Re-establishes connection to the TTS server if possible" + if (!check_rights(R_DEBUG)) + return + + message_admins("[key_name_admin(usr)] attempted to re-establish connection to the TTS HTTP server.") + log_admin("[key_name(usr)] attempted to re-establish connection to the TTS HTTP server.") + var/success = SStts.establish_connection_to_tts() + if(!success) + message_admins("[key_name_admin(usr)] failed to re-established the connection to the TTS HTTP server.") + log_admin("[key_name(usr)] failed to re-established the connection to the TTS HTTP server.") + return + message_admins("[key_name_admin(usr)] successfully re-established the connection to the TTS HTTP server.") + log_admin("[key_name(usr)] successfully re-established the connection to the TTS HTTP server.") + /proc/generate_timer_source_output(list/datum/timedevent/events) var/list/per_source = list() diff --git a/code/modules/assembly/voice.dm b/code/modules/assembly/voice.dm index e6992c7b079..52ef29f38e5 100644 --- a/code/modules/assembly/voice.dm +++ b/code/modules/assembly/voice.dm @@ -36,9 +36,9 @@ /obj/item/assembly/voice/Hear(message, atom/movable/speaker, message_language, raw_message, radio_freq, list/spans, list/message_mods = list(), message_range) . = ..() if(message_mods[WHISPER_MODE]) //Too quiet lad - return + return FALSE if(speaker == src) - return + return FALSE // raw_message can contain multiple spaces between words etc which are not seen in chat due to HTML rendering // this means if the teller records a message with e.g. double spaces or tabs, other people will not be able to trigger the sensor since they don't know how to perform the same combination @@ -49,6 +49,7 @@ else if(check_activation(speaker, raw_message)) send_pulse() + return TRUE /obj/item/assembly/voice/proc/record_speech(atom/movable/speaker, raw_message, datum/language/message_language) switch(mode) diff --git a/code/modules/client/preferences/sounds.dm b/code/modules/client/preferences/sounds.dm index 4e26a8c0a44..c5d7d4978c8 100644 --- a/code/modules/client/preferences/sounds.dm +++ b/code/modules/client/preferences/sounds.dm @@ -33,6 +33,23 @@ savefile_key = "sound_tts" savefile_identifier = PREFERENCE_PLAYER +/datum/preference/toggle/sound_tts_blips + category = PREFERENCE_CATEGORY_GAME_PREFERENCES + savefile_key = "sound_tts_blips" + savefile_identifier = PREFERENCE_PLAYER + default_value = FALSE + +/datum/preference/numeric/sound_tts_volume + category = PREFERENCE_CATEGORY_GAME_PREFERENCES + savefile_key = "sound_tts_volume" + savefile_identifier = PREFERENCE_PLAYER + + minimum = 0 + maximum = 100 + +/datum/preference/numeric/sound_tts_volume/create_default_value() + return maximum + /// Controls hearing dance machines /datum/preference/toggle/sound_jukebox category = PREFERENCE_CATEGORY_GAME_PREFERENCES diff --git a/code/modules/mob/dead/observer/observer_say.dm b/code/modules/mob/dead/observer/observer_say.dm index 522e250204f..08f3c262cc5 100644 --- a/code/modules/mob/dead/observer/observer_say.dm +++ b/code/modules/mob/dead/observer/observer_say.dm @@ -67,3 +67,4 @@ to_chat(src, html = "[link] [message]", avoid_highlighting = speaker == src) + return TRUE diff --git a/code/modules/mob/living/living_say.dm b/code/modules/mob/living/living_say.dm index 013c750c890..edcd1cc2e4b 100644 --- a/code/modules/mob/living/living_say.dm +++ b/code/modules/mob/living/living_say.dm @@ -252,9 +252,10 @@ GLOBAL_LIST_INIT(message_modes_stat_limits, list( return TRUE + /mob/living/Hear(message, atom/movable/speaker, datum/language/message_language, raw_message, radio_freq, list/spans, list/message_mods = list(), message_range=0) if(!GET_CLIENT(src)) - return + return FALSE var/deaf_message var/deaf_type @@ -268,8 +269,12 @@ GLOBAL_LIST_INIT(message_modes_stat_limits, list( var/avoid_highlight = src == (istype(holopad_speaker) ? holopad_speaker.source : speaker) var/is_custom_emote = message_mods[MODE_CUSTOM_SAY_ERASE_INPUT] + var/understood = TRUE if(!is_custom_emote) // we do not translate emotes + var/untranslated_raw_message = raw_message raw_message = translate_language(src, message_language, raw_message) // translate + if(raw_message != untranslated_raw_message) + understood = FALSE // if someone is whispering we make an extra type of message that is obfuscated for people out of range var/is_speaker_whispering = message_mods[WHISPER_MODE] @@ -303,8 +308,8 @@ GLOBAL_LIST_INIT(message_modes_stat_limits, list( message = deaf_message - show_message(message, MSG_VISUAL, deaf_message, deaf_type, avoid_highlight) - return message + var/show_message_success = show_message(message, MSG_VISUAL, deaf_message, deaf_type, avoid_highlight) + return understood && show_message_success if(speaker != src) if(!radio_freq) //These checks have to be separate, else people talking on the radio will make "You can't hear yourself!" appear when hearing people over the radio while deaf. @@ -323,9 +328,8 @@ GLOBAL_LIST_INIT(message_modes_stat_limits, list( // Recompose message for AI hrefs, language incomprehension. message = compose_message(speaker, message_language, raw_message, radio_freq, spans, message_mods) - - show_message(message, MSG_AUDIBLE, deaf_message, deaf_type, avoid_highlight) - return message + var/show_message_success = show_message(message, MSG_AUDIBLE, deaf_message, deaf_type, avoid_highlight) + return understood && show_message_success /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 @@ -357,12 +361,14 @@ GLOBAL_LIST_INIT(message_modes_stat_limits, list( // this signal ignores whispers or language translations (only used by beetlejuice component) SEND_GLOBAL_SIGNAL(COMSIG_GLOB_LIVING_SAY_SPECIAL, src, message_raw) + var/list/listened = list() for(var/atom/movable/listening_movable as anything in listening) if(!listening_movable) stack_trace("somehow theres a null returned from get_hearers_in_view() in send_speech!") continue - listening_movable.Hear(null, src, message_language, message_raw, null, spans, message_mods, message_range) + if(listening_movable.Hear(null, src, message_language, message_raw, null, spans, message_mods, message_range)) + listened += listening_movable //speech bubble var/list/speech_bubble_recipients = list() @@ -386,7 +392,7 @@ GLOBAL_LIST_INIT(message_modes_stat_limits, list( 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) + 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(","), listened, 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) diff --git a/code/modules/mob/living/simple_animal/hostile/megafauna/colossus.dm b/code/modules/mob/living/simple_animal/hostile/megafauna/colossus.dm index fcf792da957..9caaf03df62 100644 --- a/code/modules/mob/living/simple_animal/hostile/megafauna/colossus.dm +++ b/code/modules/mob/living/simple_animal/hostile/megafauna/colossus.dm @@ -262,7 +262,7 @@ . += "It is activated by [activation_method]." /obj/machinery/anomalous_crystal/Hear(message, atom/movable/speaker, message_langs, raw_message, radio_freq, spans, list/message_mods = list(), message_range) - ..() + . = ..() if(isliving(speaker)) ActivationReaction(speaker, ACTIVATE_SPEECH) diff --git a/code/modules/mob/mob.dm b/code/modules/mob/mob.dm index 02de4d93ae0..560bb6f69d9 100644 --- a/code/modules/mob/mob.dm +++ b/code/modules/mob/mob.dm @@ -217,32 +217,37 @@ */ /mob/proc/show_message(msg, type, alt_msg, alt_type, avoid_highlighting = FALSE)//Message, type of message (1 or 2), alternative message, alt message type (1 or 2) if(!client) - return + return FALSE msg = copytext_char(msg, 1, MAX_MESSAGE_LEN) + // Return TRUE if we sent the original msg, otherwise return FALSE + . = TRUE if(type) if(type & MSG_VISUAL && is_blind())//Vision related if(!alt_msg) - return + return FALSE else msg = alt_msg type = alt_type + . = FALSE if(type & MSG_AUDIBLE && !can_hear())//Hearing related if(!alt_msg) - return + return FALSE else msg = alt_msg type = alt_type + . = FALSE if(type & MSG_VISUAL && is_blind()) - return + return FALSE // voice muffling if(stat == UNCONSCIOUS || stat == HARD_CRIT) if(type & MSG_AUDIBLE) //audio to_chat(src, "... You can almost hear something ...") - return + return FALSE to_chat(src, msg, avoid_highlighting = avoid_highlighting) + return . /** * Generate a visible message from this atom diff --git a/code/modules/wiremod/components/atom/hear.dm b/code/modules/wiremod/components/atom/hear.dm index b2d3f43baba..3c3f05691b2 100644 --- a/code/modules/wiremod/components/atom/hear.dm +++ b/code/modules/wiremod/components/atom/hear.dm @@ -41,7 +41,7 @@ /obj/item/circuit_component/hear/Hear(message, atom/movable/speaker, datum/language/message_language, raw_message, radio_freq, list/spans, list/message_mods, message_range) if(speaker == parent?.shell) - return + return FALSE message_port.set_output(raw_message) if(message_language) @@ -49,3 +49,4 @@ speaker_port.set_output(speaker) speaker_name.set_output(speaker.GetVoice()) trigger_port.set_output(COMPONENT_SIGNAL) + return TRUE diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/sounds.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/sounds.tsx index a0f3cbe4d19..baa7deb91d5 100644 --- a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/sounds.tsx +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/sounds.tsx @@ -1,4 +1,4 @@ -import { CheckboxInput, FeatureToggle } from '../base'; +import { CheckboxInput, FeatureToggle, Feature, FeatureNumberInput } from '../base'; export const sound_ambience: FeatureToggle = { name: 'Enable ambience', @@ -41,6 +41,21 @@ export const sound_tts: FeatureToggle = { component: CheckboxInput, }; +export const sound_tts_blips: FeatureToggle = { + name: 'Use Blips instead of TTS', + category: 'SOUND', + description: + 'When enabled, text to speech will be replaced with blip sounds based on the voice. Does nothing if you disable TTS.', + component: CheckboxInput, +}; + +export const sound_tts_volume: Feature = { + name: 'TTS Volume', + category: 'SOUND', + description: 'The volume that the text-to-speech sounds will play at.', + component: FeatureNumberInput, +}; + export const sound_jukebox: FeatureToggle = { name: 'Enable jukebox music', category: 'SOUND', diff --git a/tools/tts/tts-api/tts-api.py b/tools/tts/tts-api/tts-api.py index 8c590ca0b13..60a0f8477e2 100644 --- a/tools/tts/tts-api/tts-api.py +++ b/tools/tts/tts-api/tts-api.py @@ -3,24 +3,25 @@ import io import gc import subprocess import requests +import re from flask import Flask, request, send_file, abort app = Flask(__name__) authorization_token = os.getenv("TTS_AUTHORIZATION_TOKEN", "coolio") -@app.route("/tts") -def text_to_speech(): - if authorization_token != request.headers.get("Authorization", ""): - abort(401) +def hhmmss_to_seconds(string): + new_time = 0 + separated_times = string.split(":") + new_time = 60 * 60 * float(separated_times[0]) + new_time += 60 * float(separated_times[1]) + new_time += float(separated_times[2]) + return new_time - voice = request.args.get("voice", '') - text = request.json.get("text", '') - - filter_complex = request.args.get("filter", '') +def text_to_speech_handler(endpoint, voice, text, filter_complex): filter_complex = filter_complex.replace("\"", "") - response = requests.get(f"http://tts-container:5003/generate-tts", json={ 'text': text, 'voice': voice }) + response = requests.get(f"http://tts-container:5003/" + endpoint, json={ 'text': text, 'voice': voice }) if response.status_code != 200: abort(500) @@ -29,9 +30,40 @@ def text_to_speech(): ffmpeg_result = subprocess.run(["ffmpeg", "-f", "wav", "-i", "pipe:0", "-filter_complex", filter_complex, "-c:a", "libvorbis", "-b:a", "64k", "-f", "ogg", "pipe:1"], input=response.content, capture_output = True) else: ffmpeg_result = subprocess.run(["ffmpeg", "-f", "wav", "-i", "pipe:0", "-c:a", "libvorbis", "-b:a", "64k", "-f", "ogg", "pipe:1"], input=response.content, capture_output = True) - print(f"ffmpeg result size: {len(ffmpeg_result.stdout)} stderr = \n{ffmpeg_result.stderr.decode()}") + ffmpeg_metadata_output = ffmpeg_result.stderr.decode() + print(f"ffmpeg result size: {len(ffmpeg_result.stdout)} stderr = \n{ffmpeg_metadata_output}") + + + matched_length = re.search(r"time=([0-9:\\.]+)", ffmpeg_metadata_output) + hh_mm_ss = matched_length.group(1) + length = hhmmss_to_seconds(hh_mm_ss) + + response = send_file(io.BytesIO(ffmpeg_result.stdout), as_attachment=True, download_name='identifier.ogg', mimetype="audio/ogg") + response.headers['audio-length'] = length + return response + +@app.route("/tts") +def text_to_speech_normal(): + if authorization_token != request.headers.get("Authorization", ""): + abort(401) + + voice = request.args.get("voice", '') + text = request.json.get("text", '') + + filter_complex = request.args.get("filter", '') + return text_to_speech_handler("generate-tts", voice, text, filter_complex) + +@app.route("/tts-blips") +def text_to_speech_blips(): + if authorization_token != request.headers.get("Authorization", ""): + abort(401) + + voice = request.args.get("voice", '') + text = request.json.get("text", '') + + filter_complex = request.args.get("filter", '') + return text_to_speech_handler("generate-tts-blips", voice, text, filter_complex) - return send_file(io.BytesIO(ffmpeg_result.stdout), as_attachment=True, download_name='identifier.ogg', mimetype="audio/ogg") @app.route("/tts-voices") diff --git a/tools/tts/tts/tts.py b/tools/tts/tts/tts.py index 196ee1df81d..90b07691112 100644 --- a/tools/tts/tts/tts.py +++ b/tools/tts/tts/tts.py @@ -4,18 +4,23 @@ import os import io import json import gc +import random from flask import Flask, request, send_file +from pydub import AudioSegment +from pydub.silence import split_on_silence tts = TTS("tts_models/en/vctk/vits", progress_bar=False, gpu=False) - +letters_to_use = "ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" +random_factor = 0.35 +os.makedirs('samples', exist_ok=True) app = Flask(__name__) voice_name_mapping = {} use_voice_name_mapping = True with open("./tts_voices_mapping.json", "r") as file: - voice_name_mapping = json.load(file) - if len(voice_name_mapping) == 0: - use_voice_name_mapping = False + voice_name_mapping = json.load(file) + if len(voice_name_mapping) == 0: + use_voice_name_mapping = False voice_name_mapping_reversed = {v: k for k, v in voice_name_mapping.items()} @@ -37,6 +42,54 @@ def text_to_speech(): request_count += 1 return result +@app.route("/generate-tts-blips") +def text_to_speech_blips(): + global request_count + text = request.json.get("text", "").upper() + voice = request.json.get("voice", "") + if use_voice_name_mapping: + voice = voice_name_mapping_reversed[voice] + + result = None + with io.BytesIO() as data_bytes: + with torch.no_grad(): + result_sound = None + if not os.path.exists('samples/' + voice): + os.makedirs('samples/' + voice, exist_ok=True) + for i, value in enumerate(letters_to_use): + tts.tts_to_file(text=value + ".", speaker=voice, file_path="samples/" + voice + "/" + value + ".wav") + loaded_word = AudioSegment.from_file("samples/" + voice + "/" + value + ".wav") + audio_chunks = split_on_silence(loaded_word, min_silence_len = 100, silence_thresh = -45, keep_silence = 50) + combined = AudioSegment.empty() + for chunk in audio_chunks: + combined += chunk + combined.export("samples/" + voice + "/" + value + ".wav", format='wav') + for i, letter in enumerate(text): + if not letter.isalpha() or letter.isnumeric() or letter == " ": + continue + if letter == ' ': + new_sound = letter_sound._spawn(b'\x00' * (22050 // 3), overrides={'frame_rate': 22050}) + new_sound = new_sound.set_frame_rate(22050) + else: + if not i % 2 == 0: + continue # Skip every other letter + if not os.path.isfile("samples/" + voice + "/" + letter + ".wav"): + continue + letter_sound = AudioSegment.from_file("samples/" + voice + "/" + letter + ".wav") + + raw = letter_sound.raw_data[2500:-2500] + octaves = 1 + random.random() * random_factor + frame_rate = int(letter_sound.frame_rate * (2.0 ** octaves)) + + new_sound = letter_sound._spawn(raw, overrides={'frame_rate': frame_rate}) + new_sound = new_sound.set_frame_rate(22050) + + result_sound = new_sound if result_sound is None else result_sound + new_sound + result_sound.export(data_bytes, format='wav') + result = send_file(io.BytesIO(data_bytes.getvalue()), mimetype="audio/wav") + request_count += 1 + return result + @app.route("/tts-voices") def voices_list(): if use_voice_name_mapping: