Files
Arrow768 14b6339d5c TGUI Migration (for everything remaining except the hardsuit) (#22404)
```
  - refactor: "Updates the Computer Fabricator to TGUI"
  - refactor: "Updates the Instruments to TGUI"
  - refactor: "Updates the Geo Scanner to TGUI"
  - refactor: "Updates the Stacking Machine to TGUI"
  - refactor: "Updates the Robotics Console to TGUI"
  - refactor: "Updates the Requests Console to TGUI"
  - refactor: "Updates the Synthesizer/Instrument main panel to TGUI"
  - refactor: "Updates the Nuclear Bomb control panel to TGUI"
  - rscadd: "Requests Console messages are now typed (Assistance Request, Supply Request, Information, Reply); the type is shown in the recipient's log, audible alert, and PDA notification."
  - rscadd: "Reconstructs the Synthesizer's Virtual Environment Editor (was broken since added — its NanoUI template never existed). The Custom environment preset that depends on it is gated behind a new musical_config.can_use_custom flag (default off, matching prior behavior)."
  - bugfix: "Fixes the Autolathe UI re-opening after every print job."
  - bugfix: "Fixes the Song Editor's currently-playing line indicator not advancing during playback."
  - bugfix: "Fixes a class of bugs where TGUI windows for the Air Alarm, Portable Turret, Mining Vendor, Mineral Processor, Tank Dispenser, Helm Console, all Chemistry machines, and Suit Cycler would force themselves back open after every action, even when the player had closed them."
  - bugfix: "Fixes the spatial sound system silently dropping every sound that used the 'None' (-1) environment id or a 23-element custom environment list. The validator was rejecting both as invalid, so synthesizer notes played with Custom or None mode produced no audible output at all."
  - admin: "Adds two requests consoles and two fax machines to the bridge on the runtime test map."

```

Claude Opus 4.7 has been used during the creation of this PR

---------

Signed-off-by: Arrow768 <1331699+Arrow768@users.noreply.github.com>
Co-authored-by: Werner <Arrow768@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Batrachophreno <Batrochophreno@gmail.com>
2026-05-26 17:57:26 +00:00

291 lines
11 KiB
Plaintext

GLOBAL_DATUM_INIT(sound_player, /singleton/sound_player, new)
/**
* A sound player/manager for looping 3D sound effects.
* Due to how the BYOND sound engine works a sound datum must be played on a specific channel for updates to work properly.
* If a channel is not assigned it will just result in a new sound effect playing, even if re-using the same datum instance.
* We also use the channel to play a null-sound on Stop(), just in case BYOND clients don't like having a large nuber, albeit stopped, looping sounds.
* As such there is a maximum limit of 1024 sound sources, with further limitations due to some channels already being potentially in use.
* However, multiple sources may share the same sound_id and there is a best-effort attempt to play the closest source where possible.
* The line above is currently a lie. Will probably just have to enforce moderately short sound ranges.
*/
/singleton/sound_player
var/list/taken_channels // taken_channels and source_id_uses can be merged into one but would then require a meta-object to store the different values I desire.
var/list/sound_tokens_by_sound_id
/singleton/sound_player/New()
..()
taken_channels = list()
sound_tokens_by_sound_id = list()
/// This can be called if either we're doing whole sound setup ourselves or it will be as part of from-file sound setup
/singleton/sound_player/proc/PlaySoundDatum(atom/source, sound_id, sound/sound, range, prefer_mute, sound_type = ASFX_AMBIENCE)
var/token_type = isnum(sound.environment) ? /datum/sound_token : /datum/sound_token/static_environment
return new token_type(source, sound_id, sound, range, prefer_mute, sound_type)
/singleton/sound_player/proc/PlayLoopingSound(atom/source, sound_id, sound, volume, range, falloff = 1, echo, frequency, prefer_mute, sound_type = ASFX_AMBIENCE)
var/sound/S = istype(sound, /sound) ? sound : new(sound)
S.environment = 0 // Ensures a 3D effect even if x/y offset happens to be 0 the first time it's played
S.volume = volume
S.falloff = falloff
S.echo = echo
S.frequency = frequency
S.repeat = TRUE
return PlaySoundDatum(source, sound_id, S, range, prefer_mute, sound_type)
/// The 'repeat = FALSE' here seems to cause scattered sound cutoff issues due to any /sounds created reverting to the default channel zero. Refer to https://github.com/Aurorastation/Aurora.3/pull/21845.
/// This suggests that further work needs to be done with managing channels when a sound gets assigned default channel 0.
/singleton/sound_player/proc/PlayNonloopingSound(atom/source, sound_id, sound, volume, range, falloff = 1, echo, frequency, prefer_mute, sound_type = ASFX_AMBIENCE)
var/sound/S = istype(sound, /sound) ? sound : new(sound)
S.environment = 0 // Ensures a 3D effect even if x/y offset happens to be 0 the first time it's played
S.volume = volume
S.falloff = falloff
S.echo = echo
S.frequency = frequency
S.repeat = FALSE
return PlaySoundDatum(source, sound_id, S, range, prefer_mute, sound_type)
/singleton/sound_player/proc/PrivStopSound(datum/sound_token/sound_token)
var/channel = sound_token.sound.channel
var/sound_id = sound_token.sound_id
var/sound_tokens = sound_tokens_by_sound_id[sound_id]
if(!(sound_token in sound_tokens))
return
sound_tokens -= sound_token
if(length(sound_tokens))
return
GLOB.sound_channels.ReleaseChannel(channel)
taken_channels -= sound_id
sound_tokens_by_sound_id -= sound_id
/singleton/sound_player/proc/PrivGetChannel(datum/sound_token/sound_token)
var/sound_id = sound_token.sound_id
. = taken_channels[sound_id] // Does this sound_id already have an assigned channel?
if(!.) // If not, request a new one.
. = GLOB.sound_channels.RequestChannel(sound_id)
if(!.) // Oh no, still no channel. Abort
return
taken_channels[sound_id] = .
var/sound_tokens = sound_tokens_by_sound_id[sound_id]
if(!sound_tokens)
sound_tokens = list()
sound_tokens_by_sound_id[sound_id] = sound_tokens
sound_tokens += sound_token
#define SOUND_STOPPED FLAG(15)
/**
* Outwardly this is a merely a toke/little helper that a user utilize to adjust sounds as desired (and possible).
* In reality this is where the heavy-lifting happens.
*/
/datum/sound_token
/// Where the sound originates from
var/atom/source
/// Assoc: Atoms hearing this sound, and their sound datum
var/list/listeners
/// How many turfs away the sound will stop playing completely
var/range
/// If sound should be muted instead of stopped when mob moves out of range. In the general case this should be avoided because listeners will remain tracked.
var/prefer_mute
/// Sound datum, holds most sound relevant data
var/sound/sound
/// The associated sound id, used for cleanup
var/sound_id
/// Paused, muted, running? Global for all listeners
var/status = 0
/// Paused, muted, running? Specific for the given listener.
var/listener_status
/// Type of sound this token is handling, it's set by default (arbitrarily) as ambience, gets set on init with the actual one if specified.
var/sound_type
/datum/sound_token/New(atom/source, sound_id, sound/sound, range = 4, prefer_mute = FALSE, sound_type = ASFX_AMBIENCE)
..()
if(!istype(source))
CRASH("Invalid sound source: [log_info_line(source)]")
if(!istype(sound))
CRASH("Invalid sound: [log_info_line(sound)]")
if(sound.repeat && !sound_id)
CRASH("No sound id given")
if(!PrivIsValidEnvironment(sound.environment))
CRASH("Invalid sound environment: [log_info_line(sound.environment)]")
src.prefer_mute = prefer_mute
src.range = range
src.source = source
src.sound = sound
src.sound_id = sound_id
src.sound_type = sound_type
if(sound.repeat) // Non-looping sounds may not reserve a sound channel due to the risk of not hearing when someone forgets to stop the token
var/channel = GLOB.sound_player.PrivGetChannel(src) //Attempt to find a channel
if(!isnum(channel))
CRASH("All available sound channels are in active use.")
sound.channel = channel
else
sound.channel = 0
listeners = list()
listener_status = list()
RegisterSignal(source, COMSIG_QDELETING, TYPE_PROC_REF(/datum, qdel_self))
PrivLocateListeners()
START_PROCESSING(SSprocessing, src)
/datum/sound_token/Destroy()
Stop()
source = null
if (listeners)
listeners.Cut()
sound = null
return ..()
/datum/sound_token/proc/SetVolume(new_volume)
new_volume = clamp(new_volume, 0, 100)
if(sound.volume == new_volume)
return
sound.volume = new_volume
PrivUpdateListeners()
/datum/sound_token/proc/Mute()
PrivUpdateStatus(status|SOUND_MUTE)
/datum/sound_token/proc/Unmute()
PrivUpdateStatus(status & ~SOUND_MUTE)
/datum/sound_token/proc/Pause()
PrivUpdateStatus(status|SOUND_PAUSED)
/// Normally called Resume but I don't want to give people false hope about being unable to un-stop a sound
/datum/sound_token/proc/Unpause()
PrivUpdateStatus(status & ~SOUND_PAUSED)
/datum/sound_token/proc/Stop()
if(status & SOUND_STOPPED)
return
status |= SOUND_STOPPED
var/sound/null_sound = new(channel = sound.channel)
for(var/listener in listeners)
PrivRemoveListener(listener, null_sound)
listeners = null
listener_status = null
UnregisterSignal(source, COMSIG_QDELETING)
source = null
GLOB.sound_player.PrivStopSound(src)
STOP_PROCESSING(SSprocessing, src)
/datum/sound_token/process()
PrivLocateListeners()
/datum/sound_token/proc/PrivLocateListeners()
if(status & SOUND_STOPPED)
return
var/current_listeners = SSspatial_grid.orthogonal_range_search(source, SPATIAL_GRID_CONTENTS_TYPE_HEARING, range)
var/new_listeners = current_listeners - listeners
if(!prefer_mute)
var/former_listeners = listeners - current_listeners
for(var/listener in former_listeners)
PrivRemoveListener(listener)
for(var/mob/listener in new_listeners)
// Only give the sound token if the listener has the preference for the type of token active, and is not deaf
if((listener.client?.prefs.sfx_toggles & sound_type) && (!isdeaf(listener)))
PrivAddListener(listener)
for(var/mob/listener in current_listeners)
PrivUpdateListenerLoc(listener)
/datum/sound_token/proc/PrivUpdateStatus(new_status)
// Once stopped, always stopped. Go ask the player to play the sound again.
if(status & SOUND_STOPPED)
return
if(new_status == status)
return
status = new_status
PrivUpdateListeners()
/datum/sound_token/proc/PrivAddListener(atom/listener)
if(listener in listeners)
return
listeners += listener
RegisterSignal(listener, COMSIG_MOVABLE_MOVED, PROC_REF(PrivUpdateListenerLoc))
RegisterSignal(listener, COMSIG_QDELETING, PROC_REF(PrivRemoveListener))
PrivUpdateListenerLoc(listener, update_sound = FALSE)
/datum/sound_token/proc/PrivRemoveListener(atom/listener, sound/null_sound)
null_sound = null_sound || new(channel = sound.channel)
sound_to(listener, null_sound)
UnregisterSignal(listener, COMSIG_MOVABLE_MOVED)
UnregisterSignal(listener, COMSIG_QDELETING)
listeners -= listener
/datum/sound_token/proc/PrivUpdateListenerLoc(atom/movable/listener, atom/old_loc, dir, forced, list/old_locs, update_sound = TRUE)
var/turf/source_turf = get_turf(source)
var/turf/listener_turf = get_turf(listener)
if (!source_turf || !listener_turf)
return
var/distance = get_dist(source_turf, listener_turf)
if(!listener_turf || (distance > range))
if(prefer_mute)
listener_status[listener] |= SOUND_MUTE
else
PrivRemoveListener(listener)
return
else if(prefer_mute)
listener_status[listener] &= ~SOUND_MUTE
sound.x = source_turf.x - listener_turf.x
sound.z = source_turf.y - listener_turf.y
sound.y = 1
// Far as I can tell from testing, sound priority just doesn't work.
// Sounds happily steal channels from each other no matter what.
sound.priority = clamp(255 - distance, 0, 255)
PrivUpdateListener(listener, update_sound)
/datum/sound_token/proc/PrivUpdateListeners()
for(var/listener in listeners)
PrivUpdateListener(listener)
/datum/sound_token/proc/PrivUpdateListener(mob/listener, update_sound = TRUE)
sound.environment = PrivGetEnvironment(listener)
sound.status = status|listener_status[listener]
if(update_sound)
sound.status |= SOUND_UPDATE
// Send the sound only if the preference for the type of sound is set and is not deaf, otherwise remove the listener.
if((listener.client?.prefs.sfx_toggles & sound_type) && (!isdeaf(listener)))
sound_to(listener, sound)
else
PrivRemoveListener(listener)
/datum/sound_token/proc/PrivGetEnvironment(listener)
var/area/A = get_area(listener)
return A && PrivIsValidEnvironment(A.sound_environment) ? A.sound_environment : sound.environment
/datum/sound_token/proc/PrivIsValidEnvironment(environment)
if(islist(environment))
return length(environment) == 23
if(isnum(environment))
return environment >= -1 && environment <= 25
return FALSE
/datum/sound_token/static_environment/PrivGetEnvironment()
return sound.environment