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: