mirror of
https://github.com/Bubberstation/Bubberstation.git
synced 2025-12-15 20:22:07 +00:00
<!-- Write **BELOW** The Headers and **ABOVE** The comments else it may not be viewable. --> <!-- You can view Contributing.MD for a detailed description of the pull request process. --> ## About The Pull Request I have circuitry theory finals in 1 hour. And as such, to distract myself from my impending doom and to stop thinking about loop currents, I have decided to start working on this. (Update: I passed) This isn't even close to how I wish our modularity to look But that's future Waterpig's problem (Note to self: Fix the no_antag button) In another news, this probably breaks stuff. <!-- Describe The Pull Request. Please be sure every change is documented or this can delay review and even discourage maintainers from merging your PR! --> <!-- Please make sure to actually test your PRs. If you have not tested your PR mention it. --> ## Why It's Good For The Game Modularity is good. The more stuff we can modularize the better, and incase it gets removed upstream it's as simple as... removing our modular override. <!-- Argue for the merits of your changes and how they benefit the game, especially if they are controversial and/or far reaching. If you can't actually explain WHY what you are doing will improve the game, then it probably isn't good for the game in the first place. --> ## Changelog <!-- If your PR modifies aspects of the game that can be concretely observed by players or admins you should add a changelog. If your change does NOT meet this description, remove this section. Be sure to properly mark your PRs to prevent unnecessary GBP loss. You can read up on GBP and it's effects on PRs in the tgstation guides for contributors. Please note that maintainers freely reserve the right to remove and add tags should they deem it appropriate. You can attempt to finagle the system all you want, but it's best to shoot for clear communication right off the bat. --> 🆑 refactor: Refactors modularity significantly /🆑 <!-- Both 🆑's are required for the changelog to work! You can put your name to the right of the first 🆑 if you want to overwrite your GitHub username as author ingame. --> <!-- You can use multiple of the same prefix (they're only used for the icon ingame) and delete the unneeded ones. Despite some of the tags, changelogs should generally represent how a player might be affected by the changes rather than a summary of the PR's contents. --> <!-- By opening a pull request. You have read and understood the repository rules located on the main README.md on this project. --> --------- Co-authored-by: The Sharkening <95130227+StrangeWeirdKitten@users.noreply.github.com>
417 lines
17 KiB
Plaintext
417 lines
17 KiB
Plaintext
SUBSYSTEM_DEF(tts)
|
|
name = "Text To Speech"
|
|
wait = 0.05 SECONDS
|
|
priority = FIRE_PRIORITY_TTS
|
|
init_order = INIT_ORDER_TTS
|
|
runlevels = RUNLEVEL_LOBBY | RUNLEVEL_SETUP | RUNLEVEL_GAME | RUNLEVEL_POSTGAME
|
|
|
|
/// Queued HTTP requests that have yet to be sent. TTS requests are handled as lists rather than datums.
|
|
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/vv_edit_var(var_name, var_value)
|
|
// tts being enabled depends on whether it actually exists
|
|
if(NAMEOF(src, tts_enabled) == var_name)
|
|
return FALSE
|
|
return ..()
|
|
|
|
/datum/controller/subsystem/tts/stat_entry(msg)
|
|
msg = "Active:[length(in_process_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 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, 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()
|
|
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/datum/language_holder/holder = listening_mob.get_language_holder()
|
|
var/audio_to_use = (tts_pref == TTS_SOUND_BLIPS) ? audio_blips : audio
|
|
if(!holder.has_language(language))
|
|
continue
|
|
if(get_dist(listening_mob, turf_source) <= range)
|
|
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
|