diff --git a/Content.Client/Options/UI/Tabs/ExtraTab.xaml b/Content.Client/Options/UI/Tabs/ExtraTab.xaml
index 0113e528d6..b5e2807794 100644
--- a/Content.Client/Options/UI/Tabs/ExtraTab.xaml
+++ b/Content.Client/Options/UI/Tabs/ExtraTab.xaml
@@ -31,7 +31,6 @@
-
diff --git a/Content.Client/Options/UI/Tabs/ExtraTab.xaml.cs b/Content.Client/Options/UI/Tabs/ExtraTab.xaml.cs
index 40a3a29de9..3d65961233 100644
--- a/Content.Client/Options/UI/Tabs/ExtraTab.xaml.cs
+++ b/Content.Client/Options/UI/Tabs/ExtraTab.xaml.cs
@@ -32,11 +32,6 @@ public sealed partial class ExtraTab : Control
SliderTtsRadio,
scale: ContentAudioSystem.TtsMultiplier);
- Control.AddOptionPercentSlider(
- SunriseCCVars.TTSAnnounceVolume,
- SliderTtsAnnounce,
- scale: ContentAudioSystem.TtsMultiplier);
-
Control.AddOptionCheckBox(SunriseCCVars.TTSClientEnabled, TtsClientCheckBox);
Control.AddOptionCheckBox(SunriseCCVars.TTSClientQueueEnabled, TtsClientCheckBoxQueue);
Control.AddOptionCheckBox(SunriseCCVars.TTSRadioGhostEnabled, TtsRadioGhostCheckBox);
diff --git a/Content.Client/_Sunrise/TTS/TTSSystem.cs b/Content.Client/_Sunrise/TTS/TTSSystem.cs
index 6772cf7a97..e311f4d56d 100644
--- a/Content.Client/_Sunrise/TTS/TTSSystem.cs
+++ b/Content.Client/_Sunrise/TTS/TTSSystem.cs
@@ -36,25 +36,22 @@ public sealed class TTSSystem : EntitySystem
private float _volume;
private float _radioVolume;
private int _fileIdx;
- private float _volumeAnnounce;
private bool _isQueueEnabled;
private bool _ghostRadioEnabled;
private readonly Queue _ttsQueue = new();
private (EntityUid Entity, AudioComponent Component)? _currentPlaying;
private static readonly AudioResource EmptyAudioResource = new();
- public sealed class QueuedTts(byte[] data, TtsType ttsType, ResolvedSoundSpecifier? announcementSound = null)
+ public sealed class QueuedTts(byte[] data, TtsType ttsType)
{
public byte[] Data = data;
- public ResolvedSoundSpecifier? AnnouncementSound = announcementSound;
public TtsType TtsType = ttsType;
}
public enum TtsType
{
Voice,
- Radio,
- Announce
+ Radio
}
public override void Initialize()
@@ -63,12 +60,10 @@ public sealed class TTSSystem : EntitySystem
_res.AddRoot(Prefix, ContentRoot);
_cfg.OnValueChanged(SunriseCCVars.TTSVolume, OnTtsVolumeChanged, true);
_cfg.OnValueChanged(SunriseCCVars.TTSRadioVolume, OnTtsRadioVolumeChanged, true);
- _cfg.OnValueChanged(SunriseCCVars.TTSAnnounceVolume, OnTtsAnnounceVolumeChanged, true);
_cfg.OnValueChanged(SunriseCCVars.TTSClientEnabled, OnTtsClientOptionChanged, true);
_cfg.OnValueChanged(SunriseCCVars.TTSClientQueueEnabled, OnTTSQueueOptionChanged, true);
_cfg.OnValueChanged(SunriseCCVars.TTSRadioGhostEnabled, OnTtsRadioGhostChanged, true);
SubscribeNetworkEvent(OnPlayTTS);
- SubscribeNetworkEvent(OnAnnounceTTSPlay);
}
public override void Shutdown()
@@ -76,7 +71,6 @@ public sealed class TTSSystem : EntitySystem
base.Shutdown();
_cfg.UnsubValueChanged(SunriseCCVars.TTSVolume, OnTtsVolumeChanged);
_cfg.UnsubValueChanged(SunriseCCVars.TTSRadioVolume, OnTtsRadioVolumeChanged);
- _cfg.UnsubValueChanged(SunriseCCVars.TTSAnnounceVolume, OnTtsAnnounceVolumeChanged);
_cfg.UnsubValueChanged(SunriseCCVars.TTSClientEnabled, OnTtsClientOptionChanged);
_cfg.UnsubValueChanged(SunriseCCVars.TTSClientQueueEnabled, OnTTSQueueOptionChanged);
_cfg.UnsubValueChanged(SunriseCCVars.TTSRadioGhostEnabled, OnTtsRadioGhostChanged);
@@ -105,10 +99,6 @@ public sealed class TTSSystem : EntitySystem
{
_isQueueEnabled = option;
}
- private void OnTtsAnnounceVolumeChanged(float volume)
- {
- _volumeAnnounce = volume;
- }
private void OnTtsClientOptionChanged(bool option)
{
@@ -120,15 +110,7 @@ public sealed class TTSSystem : EntitySystem
_ghostRadioEnabled = option;
}
- private void OnAnnounceTTSPlay(AnnounceTtsEvent ev)
- {
- if (_volumeAnnounce == 0)
- return;
- var entry = new QueuedTts(ev.Data, TtsType.Announce, ev.AnnouncementSound);
-
- _ttsQueue.Enqueue(entry);
- }
private void PlayNextInQueue()
{
@@ -145,9 +127,6 @@ public sealed class TTSSystem : EntitySystem
case TtsType.Radio:
volume = _radioVolume;
break;
- case TtsType.Announce:
- volume = _volumeAnnounce;
- break;
case TtsType.Voice:
volume = _volume;
break;
@@ -155,10 +134,6 @@ public sealed class TTSSystem : EntitySystem
var finalParams = AudioParams.Default.WithVolume(SharedAudioSystem.GainToVolume(volume));
- if (entry.AnnouncementSound != null)
- {
- _currentPlaying = _audio.PlayGlobal(entry.AnnouncementSound, new EntityUid(), finalParams.AddVolume(-5f));
- }
_currentPlaying = PlayTTSBytes(entry.Data, null, finalParams, true);
}
diff --git a/Content.Server/Chat/Systems/ChatSystem.cs b/Content.Server/Chat/Systems/ChatSystem.cs
index 706d0e88a7..d787de369d 100644
--- a/Content.Server/Chat/Systems/ChatSystem.cs
+++ b/Content.Server/Chat/Systems/ChatSystem.cs
@@ -3,6 +3,7 @@ using System.Linq;
using System.Text;
using Content.Server._Sunrise.Chat;
using Content.Server._Sunrise.Chat.Sanitization;
+using Content.Server._Sunrise.AnnouncementSpeaker;
using Content.Server.Administration.Logs;
using Content.Server.Administration.Managers;
using Content.Server.Chat.Managers;
@@ -66,6 +67,7 @@ public sealed partial class ChatSystem : SharedChatSystem
[Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!;
[Dependency] private readonly ExamineSystemShared _examineSystem = default!;
[Dependency] private readonly SharedPopupSystem _popupSystem = default!;
+ [Dependency] private readonly AnnouncementSpeakerSystem _announcementSpeaker = default!;
public const string DefaultAnnouncementSound = "/Audio/_Sunrise/Announcements/announce_dig.ogg"; // Sunrise-edit
@@ -350,7 +352,7 @@ public sealed partial class ChatSystem : SharedChatSystem
#region Announcements
///
- /// Dispatches an announcement to all.
+ /// Dispatches an announcement to all stations through their speaker networks.
///
/// The contents of the message
/// The sender (Communications Console in Communications Console Announcement)
@@ -370,19 +372,25 @@ public sealed partial class ChatSystem : SharedChatSystem
sender ??= Loc.GetString("chat-manager-sender-announcement");
var wrappedMessage = Loc.GetString("chat-manager-sender-announcement-wrap-message", ("sender", sender), ("message", FormattedMessage.EscapeText(message)));
- _chatManager.ChatMessageToAll(ChatChannel.Radio, message, wrappedMessage, default, false, true, colorOverride);
-
- // Sunrise-start
- if (playDefault && announcementSound == null)
+
+ // Sunrise-start - Only show in chat for players with working speakers nearby
+ var filteredPlayers = GetPlayersWithWorkingSpeakers();
+ if (filteredPlayers.Recipients.Any())
{
- announcementSound ??= new SoundPathSpecifier(DefaultAnnouncementSound);
+ _chatManager.ChatMessageToManyFiltered(filteredPlayers, ChatChannel.Radio, message, wrappedMessage, default, false, true, colorOverride);
}
+ // Sunrise-end
- if (playTts && announcementSound != null)
+ // Sunrise-start - Use speaker network instead of global broadcast
+ if (playTts && (playDefault || announcementSound != null))
{
- //_audio.PlayGlobal(announcementSound ?? DefaultAnnouncementSound, Filter.Broadcast(), true, AudioParams.Default.WithVolume(-2f));
- var announcementEv = new AnnouncementSpokeEvent(Filter.Broadcast(), message, _audio.ResolveSound(announcementSound), announceVoice);
- RaiseLocalEvent(announcementEv);
+ if (playDefault && announcementSound == null)
+ {
+ announcementSound = new SoundPathSpecifier(DefaultAnnouncementSound);
+ }
+
+ // Send announcement to all stations through their speaker networks
+ _announcementSpeaker.DispatchAnnouncementToAllStations(message, announcementSound, announceVoice);
}
// Sunrise-end
@@ -413,17 +421,47 @@ public sealed partial class ChatSystem : SharedChatSystem
sender ??= Loc.GetString("chat-manager-sender-announcement");
var wrappedMessage = Loc.GetString("chat-manager-sender-announcement-wrap-message", ("sender", sender), ("message", FormattedMessage.EscapeText(message)));
- _chatManager.ChatMessageToManyFiltered(filter, ChatChannel.Radio, message, wrappedMessage, source ?? default, false, true, colorOverride);
- // Sunrise-start
- if (playDefault && announcementSound == null)
- announcementSound = new SoundPathSpecifier(DefaultAnnouncementSound);
-
- if (playTts && announcementSound != null)
+
+ // Sunrise-start - Filter chat recipients by working speakers
+ var filteredChatPlayers = FilterPlayersByWorkingSpeakers(filter);
+ if (filteredChatPlayers.Recipients.Any())
{
- //_audio.PlayGlobal(announcementSound ?? DefaultAnnouncementSound, filter, true, AudioParams.Default.WithVolume(-2f));
- RaiseLocalEvent(new AnnouncementSpokeEvent(filter, message, _audio.ResolveSound(announcementSound), announceVoice));
+ _chatManager.ChatMessageToManyFiltered(filteredChatPlayers, ChatChannel.Radio, message, wrappedMessage, source ?? default, false, true, colorOverride);
}
- // Sunrise-edit
+ // Sunrise-end
+
+ // Sunrise-start - For filtered announcements, we may want to try speaker network if source is on a station
+ if (playTts && (playDefault || announcementSound != null))
+ {
+ if (playDefault && announcementSound == null)
+ announcementSound = new SoundPathSpecifier(DefaultAnnouncementSound);
+
+ var resolvedSound = announcementSound != null ? _audio.ResolveSound(announcementSound) : null;
+
+ // If we have a source, try to use the station's speaker network
+ if (source != null)
+ {
+ var station = _stationSystem.GetOwningStation(source.Value);
+ if (station != null)
+ {
+ _announcementSpeaker.DispatchAnnouncementToSpeakers(station.Value, message, announcementSound, announceVoice);
+ }
+ else
+ {
+ // Fallback to old broadcast system for non-station sources
+ var announcementEv = new AnnouncementSpokeEvent(filter, message, resolvedSound, announceVoice);
+ RaiseLocalEvent(announcementEv);
+ }
+ }
+ else
+ {
+ // No source, fallback to old broadcast system
+ var announcementEv = new AnnouncementSpokeEvent(filter, message, resolvedSound, announceVoice);
+ RaiseLocalEvent(announcementEv);
+ }
+ }
+ // Sunrise-end
+
_adminLogger.Add(LogType.Chat, LogImpact.Low, $"Station Announcement from {sender}: {message}");
}
@@ -462,18 +500,24 @@ public sealed partial class ChatSystem : SharedChatSystem
var filter = _stationSystem.GetInStation(stationDataComp);
- _chatManager.ChatMessageToManyFiltered(filter, ChatChannel.Radio, message, wrappedMessage, source, false, true, colorOverride);
-
- // Sunrise-start
- if (playDefault && announcementSound == null)
- announcementSound = new SoundPathSpecifier(DefaultAnnouncementSound);
-
- if (playTts && announcementSound != null)
+ // Sunrise-start - Filter chat recipients by working speakers
+ var filteredChatPlayers = FilterPlayersByWorkingSpeakers(filter);
+ if (filteredChatPlayers.Recipients.Any())
{
- //_audio.PlayGlobal(announcementSound ?? DefaultAnnouncementSound, filter, true, AudioParams.Default.WithVolume(-2f));
- RaiseLocalEvent(new AnnouncementSpokeEvent(filter, message, _audio.ResolveSound(announcementSound), announceVoice));
+ _chatManager.ChatMessageToManyFiltered(filteredChatPlayers, ChatChannel.Radio, message, wrappedMessage, source, false, true, colorOverride);
}
- // Sunrise-edit
+ // Sunrise-end
+
+ // Sunrise-start - Use speaker network for station announcements
+ if (playTts && (playDefault || announcementSound != null))
+ {
+ if (playDefault && announcementSound == null)
+ announcementSound = new SoundPathSpecifier(DefaultAnnouncementSound);
+
+ // Send announcement to this specific station's speaker network
+ _announcementSpeaker.DispatchAnnouncementToSpeakers(station.Value, message, announcementSound, announceVoice);
+ }
+ // Sunrise-end
_adminLogger.Add(LogType.Chat, LogImpact.Low, $"Station Announcement on {station} from {sender}: {message}");
}
@@ -823,6 +867,53 @@ public sealed partial class ChatSystem : SharedChatSystem
#region Utility
+ ///
+ /// Gets all players who have working announcement speakers nearby.
+ /// Used to filter chat recipients for announcements.
+ ///
+ private Filter GetPlayersWithWorkingSpeakers()
+ {
+ var filteredPlayers = Filter.Empty();
+
+ foreach (var player in _playerManager.Sessions)
+ {
+ if (player.AttachedEntity is not { Valid: true } playerEntity)
+ continue;
+
+ if (_announcementSpeaker.HasWorkingSpeakersNearby(playerEntity))
+ {
+ filteredPlayers = filteredPlayers.AddPlayer(player);
+ }
+ }
+
+ return filteredPlayers;
+ }
+
+ ///
+ /// Filters an existing filter to only include players with working speakers nearby.
+ ///
+ private Filter FilterPlayersByWorkingSpeakers(Filter originalFilter)
+ {
+ var filteredPlayers = Filter.Empty();
+
+ foreach (var player in originalFilter.Recipients)
+ {
+ if (player.AttachedEntity is not { Valid: true } playerEntity)
+ continue;
+
+ if (_announcementSpeaker.HasWorkingSpeakersNearby(playerEntity))
+ {
+ filteredPlayers = filteredPlayers.AddPlayer(player);
+ }
+ }
+
+ return filteredPlayers;
+ }
+
+ #endregion
+
+ #region Utility
+
private enum MessageRangeCheckResult
{
Disallowed,
diff --git a/Content.Server/Communications/CommunicationsConsoleComponent.cs b/Content.Server/Communications/CommunicationsConsoleComponent.cs
index 45918de7cf..ffe76ad21a 100644
--- a/Content.Server/Communications/CommunicationsConsoleComponent.cs
+++ b/Content.Server/Communications/CommunicationsConsoleComponent.cs
@@ -75,7 +75,7 @@ namespace Content.Server.Communications
/// In practise this removes the "Sent by ScugMcWawa (Slugcat Captain)" at the bottom of the announcement.
///
[DataField]
- public bool AnnounceSentBy = true;
+ public bool AnnounceSentBy = false;
// Sunrise-Start
[DataField("announceVoice", customTypeSerializer:typeof(PrototypeIdSerializer))]
diff --git a/Content.Server/Communications/CommunicationsConsoleSystem.cs b/Content.Server/Communications/CommunicationsConsoleSystem.cs
index 21ab244d66..98b7a2078c 100644
--- a/Content.Server/Communications/CommunicationsConsoleSystem.cs
+++ b/Content.Server/Communications/CommunicationsConsoleSystem.cs
@@ -262,23 +262,23 @@ namespace Content.Server.Communications
if (comp.AnnounceSentBy)
msg += "\n" + Loc.GetString("comms-console-announcement-sent-by") + " " + author;
+ // Sunrise-start
+ var voice = comp.AnnounceVoice;
+ if (TryComp(message.Actor, out var ttsComponent))
+ {
+ voice = ttsComponent.VoicePrototypeId;
+ }
+ // Sunrise-end
if (comp.Global)
{
- // Sunrise-start
- var voice = comp.AnnounceVoice;
- if (TryComp(message.Actor, out var ttsComponent))
- {
- voice = ttsComponent.VoicePrototypeId;
- }
- // Sunrise-end
_chatSystem.DispatchGlobalAnnouncement(msg, title, announcementSound: comp.Sound, colorOverride: comp.Color, announceVoice: voice); // Sunrise-edit
_adminLogger.Add(LogType.Chat, LogImpact.Low, $"{ToPrettyString(message.Actor):player} has sent the following global announcement: {msg}");
return;
}
- _chatSystem.DispatchStationAnnouncement(uid, msg, title, colorOverride: comp.Color, announceVoice: comp.AnnounceVoice);
+ _chatSystem.DispatchStationAnnouncement(uid, msg, title, colorOverride: comp.Color, announceVoice: voice);
_adminLogger.Add(LogType.Chat, LogImpact.Low, $"{ToPrettyString(message.Actor):player} has sent the following station announcement: {msg}");
diff --git a/Content.Server/_Sunrise/AnnouncementSpeaker/AnnouncementSpeakerSystem.cs b/Content.Server/_Sunrise/AnnouncementSpeaker/AnnouncementSpeakerSystem.cs
new file mode 100644
index 0000000000..25afb80a39
--- /dev/null
+++ b/Content.Server/_Sunrise/AnnouncementSpeaker/AnnouncementSpeakerSystem.cs
@@ -0,0 +1,415 @@
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Threading.Tasks;
+using Content.Server.Station.Systems;
+using Content.Server._Sunrise.TTS;
+using Content.Server.Power.Components;
+using Content.Shared._Sunrise.AnnouncementSpeaker.Components;
+using Content.Shared._Sunrise.AnnouncementSpeaker.Events;
+using Content.Shared._Sunrise.TTS;
+using Content.Shared.Station.Components;
+using Robust.Shared.Audio;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.Configuration;
+using Robust.Shared.Prototypes;
+using Content.Shared._Sunrise.SunriseCCVars;
+using Robust.Shared.Timing;
+
+namespace Content.Server._Sunrise.AnnouncementSpeaker;
+
+///
+/// Represents a queued announcement waiting to be played.
+///
+public sealed class QueuedAnnouncement
+{
+ public EntityUid Station { get; set; }
+ public string Message { get; set; } = "";
+ public ResolvedSoundSpecifier? AnnouncementSound { get; set; }
+ public string? AnnounceVoice { get; set; }
+ public byte[]? TtsData { get; set; }
+ public TimeSpan QueuedAt { get; set; }
+
+ public QueuedAnnouncement(EntityUid station, string message, ResolvedSoundSpecifier? announcementSound, string? announceVoice, byte[]? ttsData)
+ {
+ Station = station;
+ Message = message;
+ AnnouncementSound = announcementSound;
+ AnnounceVoice = announceVoice;
+ TtsData = ttsData;
+ }
+}
+
+///
+/// System that manages announcement speakers distributed across stations.
+/// Replaces global announcements with spatial audio from speaker networks.
+///
+public sealed class AnnouncementSpeakerSystem : EntitySystem
+{
+ [Dependency] private readonly SharedAudioSystem _audioSystem = default!;
+ [Dependency] private readonly IConfigurationManager _cfg = default!;
+ [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+
+ private bool _isEnabled;
+ private string _defaultAnnounceVoice = "Hanson";
+
+ // Queue system for preventing overlapping announcements
+ private readonly Queue _announcementQueue = new();
+ private bool _isPlayingAnnouncement = false;
+ private TimeSpan _currentAnnouncementEndTime;
+ private const float AnnouncementDurationEstimate = 5.0f; // Estimate 5 seconds per announcement
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ _cfg.OnValueChanged(SunriseCCVars.TTSEnabled, v => _isEnabled = v, true);
+ SubscribeLocalEvent(OnAnnouncementSpeaker);
+ // Note: SpeakerPlayAnnouncementEvent is handled by TTSSystem for the component
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+ ProcessAnnouncementQueue();
+ }
+
+ ///
+ /// Processes the announcement queue and plays the next announcement if none is currently playing.
+ ///
+ private void ProcessAnnouncementQueue()
+ {
+ // Check if current announcement has finished
+ if (_isPlayingAnnouncement && _timing.CurTime >= _currentAnnouncementEndTime)
+ {
+ _isPlayingAnnouncement = false;
+ }
+
+ // If not playing and have queued announcements, play next one
+ if (!_isPlayingAnnouncement && _announcementQueue.Count > 0)
+ {
+ var announcement = _announcementQueue.Dequeue();
+ PlayAnnouncementNow(announcement);
+ }
+ }
+
+ ///
+ /// Immediately plays an announcement without queuing.
+ ///
+ private void PlayAnnouncementNow(QueuedAnnouncement announcement)
+ {
+ var duration = TimeSpan.FromSeconds(AnnouncementDurationEstimate);
+ if (announcement.TtsData != null)
+ duration = GetAudioDurationFromBytes(announcement.TtsData);
+ _isPlayingAnnouncement = true;
+ _currentAnnouncementEndTime = _timing.CurTime + duration;
+
+ var ev = new AnnouncementSpeakerEvent(
+ announcement.Station,
+ announcement.Message,
+ announcement.AnnouncementSound,
+ announcement.AnnounceVoice,
+ announcement.TtsData
+ );
+
+ RaiseLocalEvent(ref ev);
+ }
+
+ ///
+ /// Handles station-wide announcements by finding all speakers on the station and playing the announcement through them.
+ ///
+ private void OnAnnouncementSpeaker(ref AnnouncementSpeakerEvent ev)
+ {
+ // Find all speakers on the station
+ var speakers = GetStationSpeakers(ev.Station);
+
+ if (speakers.Count == 0)
+ {
+ // Fallback: If no speakers are found, log a warning
+ // In the future, this could send to a single communications console or similar
+ Logger.Warning($"No announcement speakers found on station {ToPrettyString(ev.Station)}. Announcement not played: {ev.Message}");
+ return;
+ }
+
+ // Play announcement sound via PVS for each speaker on server side
+ if (ev.AnnouncementSound != null)
+ {
+ foreach (var speaker in speakers)
+ {
+ if (!TryComp(speaker, out var speakerComp))
+ continue;
+
+ // Check if speaker is enabled and has power
+ if (!speakerComp.Enabled)
+ continue;
+
+ if (speakerComp.RequiresPower)
+ {
+ if (!TryComp(speaker, out var powerReceiver) || !powerReceiver.Powered)
+ continue;
+ }
+
+ // Play announcement sound via PVS from this speaker
+ var audioParams = AudioParams.Default.WithVolume(-2f * speakerComp.VolumeModifier).WithMaxDistance(speakerComp.Range);
+ _audioSystem.PlayPvs(ev.AnnouncementSound, speaker, audioParams);
+ }
+ }
+
+ // Передаём TTS сразу всем динамикам
+ var speakerEvent = new SpeakerPlayAnnouncementEvent(ev.Message, ev.AnnouncementSound, ev.AnnounceVoice, ev.TtsData);
+ foreach (var speaker in speakers)
+ {
+ RaiseLocalEvent(speaker, ref speakerEvent);
+ }
+ }
+
+ ///
+ /// Gets all functional announcement speakers on a station.
+ ///
+ private List GetStationSpeakers(EntityUid station)
+ {
+ var speakers = new List();
+
+ if (!TryComp(station, out var stationData))
+ return speakers;
+
+ // Look through all grids on the station for speakers
+ foreach (var grid in stationData.Grids)
+ {
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var speakerComp, out var xform))
+ {
+ // Check if the speaker is on this grid
+ if (xform.GridUid == grid)
+ {
+ speakers.Add(uid);
+ }
+ }
+ }
+
+ return speakers;
+ }
+
+ ///
+ /// Dispatches an announcement to all speakers on a station.
+ /// This is the main entry point for the announcement speaker system.
+ /// Uses the queue system to prevent overlapping announcements.
+ ///
+ public async void DispatchAnnouncementToSpeakers(EntityUid station, string message, SoundSpecifier? announcementSound = null, string? announceVoice = null)
+ {
+ var resolvedSound = announcementSound != null ? _audioSystem.ResolveSound(announcementSound) : null;
+ if (!_isEnabled)
+ return;
+ if (!GetVoicePrototype(announceVoice ?? _defaultAnnounceVoice, out var protoVoice))
+ return;
+ var generatedTts = await GenerateTtsForAnnouncement(message, protoVoice);
+
+ var queuedAnnouncement = new QueuedAnnouncement(station, message, resolvedSound, announceVoice, generatedTts)
+ {
+ QueuedAt = _timing.CurTime
+ };
+
+ if (!_isPlayingAnnouncement)
+ {
+ // If no announcement is playing, play immediately
+ PlayAnnouncementNow(queuedAnnouncement);
+ }
+ else
+ {
+ // Queue the announcement to play after current one finishes
+ _announcementQueue.Enqueue(queuedAnnouncement);
+ }
+ }
+
+ ///
+ /// Dispatches an announcement to speakers on all stations.
+ /// Used for server-wide announcements like round start/end.
+ /// Uses the queue system to prevent overlapping announcements.
+ ///
+ public async void DispatchAnnouncementToAllStations(string message, SoundSpecifier? announcementSound = null, string? announceVoice = null)
+ {
+ var resolvedSound = announcementSound != null ? _audioSystem.ResolveSound(announcementSound) : null;
+ if (!_isEnabled)
+ return;
+ if (!GetVoicePrototype(announceVoice ?? _defaultAnnounceVoice, out var protoVoice))
+ return;
+ var generatedTts = await GenerateTtsForAnnouncement(message, protoVoice);
+
+ var stationQuery = EntityQueryEnumerator();
+ while (stationQuery.MoveNext(out var stationUid, out var stationData))
+ {
+ var queuedAnnouncement = new QueuedAnnouncement(stationUid, message, resolvedSound, announceVoice, generatedTts)
+ {
+ QueuedAt = _timing.CurTime
+ };
+
+ if (!_isPlayingAnnouncement)
+ {
+ // If no announcement is playing, play immediately
+ PlayAnnouncementNow(queuedAnnouncement);
+ }
+ else
+ {
+ // Queue the announcement to play after current one finishes
+ _announcementQueue.Enqueue(queuedAnnouncement);
+ }
+ }
+ }
+
+ ///
+ /// Gets a voice prototype by ID, with fallback to default voice.
+ ///
+ private bool GetVoicePrototype(string voiceId, [NotNullWhen(true)] out TTSVoicePrototype? voicePrototype)
+ {
+ if (!_prototypeManager.TryIndex(voiceId, out voicePrototype))
+ {
+ return _prototypeManager.TryIndex("father_grigori", out voicePrototype);
+ }
+ return true;
+ }
+
+ ///
+ /// Generates TTS audio for an announcement with the megaphone effect.
+ ///
+ private async Task GenerateTtsForAnnouncement(string text, TTSVoicePrototype voicePrototype)
+ {
+ try
+ {
+ var textSanitized = Sanitize(text);
+ if (textSanitized == "") return null;
+ if (char.IsLetter(textSanitized[^1]))
+ textSanitized += ".";
+
+ // Use TTS manager directly to generate with megaphone effect
+ var ttsManager = IoCManager.Resolve();
+ return await ttsManager.ConvertTextToSpeechAnnounce(voicePrototype, textSanitized);
+ }
+ catch (Exception e)
+ {
+ Logger.Error($"TTS System error in announcement generation: {e.Message}");
+ }
+ return null;
+ }
+
+ ///
+ /// Sanitizes text for TTS generation.
+ ///
+ private string Sanitize(string text)
+ {
+ return text.Trim();
+ }
+
+ ///
+ /// Checks if a player has any working announcement speakers within range.
+ /// Used to determine if they should receive announcement messages in chat.
+ ///
+ public bool HasWorkingSpeakersNearby(EntityUid playerEntity)
+ {
+ if (!TryComp(playerEntity, out var playerTransform))
+ return false;
+
+ var playerPos = playerTransform.Coordinates;
+
+ // Find all speakers and check if any are in range and working
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var speakerUid, out var speakerComp, out var speakerTransform))
+ {
+ // Check if speaker is enabled
+ if (!speakerComp.Enabled)
+ continue;
+
+ // Check if speaker has power (if required)
+ if (speakerComp.RequiresPower)
+ {
+ if (!TryComp(speakerUid, out var powerReceiver) || !powerReceiver.Powered)
+ continue;
+ }
+
+ // Check if player is within range of this speaker
+ if (playerPos.TryDistance(EntityManager, speakerTransform.Coordinates, out var distance) &&
+ distance <= speakerComp.Range)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Gets all speakers on all stations that are working.
+ /// Used to determine if any announcements can be played at all.
+ ///
+ public bool HasAnyWorkingSpeakers()
+ {
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var speakerUid, out var speakerComp))
+ {
+ // Check if speaker is enabled
+ if (!speakerComp.Enabled)
+ continue;
+
+ // Check if speaker has power (if required)
+ if (speakerComp.RequiresPower)
+ {
+ if (!TryComp(speakerUid, out var powerReceiver) || !powerReceiver.Powered)
+ continue;
+ }
+
+ return true; // Found at least one working speaker
+ }
+
+ return false;
+ }
+
+ private static TimeSpan GetWavDurationFromBytes(byte[] wavData)
+ {
+ // WAV header is 44 bytes for PCM
+ if (wavData.Length < 44)
+ return TimeSpan.Zero;
+ int sampleRate = BitConverter.ToInt32(wavData, 24);
+ short channels = BitConverter.ToInt16(wavData, 22);
+ short bitsPerSample = BitConverter.ToInt16(wavData, 34);
+ int dataSize = BitConverter.ToInt32(wavData, 40);
+ int bytesPerSample = bitsPerSample / 8;
+ int totalSamples = dataSize / (bytesPerSample * channels);
+ if (sampleRate <= 0 || channels <= 0 || bitsPerSample <= 0)
+ return TimeSpan.Zero;
+ double durationSeconds = (double)totalSamples / sampleRate;
+ return TimeSpan.FromSeconds(durationSeconds);
+ }
+
+ private static TimeSpan GetAudioDurationFromBytes(byte[] audioData, string? fileType = null)
+ {
+ if (fileType == "wav" || (audioData.Length > 12 && audioData[0] == 'R' && audioData[1] == 'I' && audioData[2] == 'F' && audioData[8] == 'W' && audioData[9] == 'A' && audioData[10] == 'V'))
+ {
+ return GetWavDurationFromBytes(audioData);
+ }
+ if (fileType == "ogg" || (audioData.Length > 4 && audioData[0] == 'O' && audioData[1] == 'g' && audioData[2] == 'g' && audioData[3] == 'S'))
+ {
+ try
+ {
+ var nvorbisType = Type.GetType("NVorbis.VorbisReader, NVorbis");
+ if (nvorbisType != null)
+ {
+ using var stream = new MemoryStream(audioData);
+ using var reader = (IDisposable)Activator.CreateInstance(nvorbisType, stream, false)!;
+ var totalTimeProp = nvorbisType.GetProperty("TotalTime");
+ if (totalTimeProp != null)
+ {
+ var totalTime = totalTimeProp.GetValue(reader);
+ if (totalTime is TimeSpan ts)
+ return ts;
+ }
+ }
+ }
+ catch
+ {
+ // NVorbis не доступен или ошибка — fallback
+ }
+ double fallbackBitrate = 128000.0; // 128 кбит/с
+ double duration = audioData.Length * 8.0 / fallbackBitrate;
+ return TimeSpan.FromSeconds(duration);
+ }
+ return TimeSpan.Zero;
+ }
+}
diff --git a/Content.Server/_Sunrise/TTS/TTSManager.cs b/Content.Server/_Sunrise/TTS/TTSManager.cs
index ee9fa05057..41ae47be16 100644
--- a/Content.Server/_Sunrise/TTS/TTSManager.cs
+++ b/Content.Server/_Sunrise/TTS/TTSManager.cs
@@ -42,12 +42,16 @@ public sealed class TTSManager
private ISawmill _sawmill = default!;
private string _apiUrl = string.Empty;
+ private string _radioEffect = string.Empty;
+ private string _announceEffect = string.Empty;
public void Initialize()
{
_sawmill = Logger.GetSawmill("tts");
_cfg.OnValueChanged(SunriseCCVars.TTSApiUrl, OnApiUrlChanged, true);
_cfg.OnValueChanged(SunriseCCVars.TTSApiToken, OnApiTokenChanged, true);
+ _cfg.OnValueChanged(SunriseCCVars.TTSRadioEffect, OnRadioEffectChanged, true);
+ _cfg.OnValueChanged(SunriseCCVars.TTSAnnounceEffect, OnAnnounceEffectChanged, true);
}
private void OnApiUrlChanged(string value)
@@ -60,6 +64,16 @@ public sealed class TTSManager
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", value);
}
+ private void OnRadioEffectChanged(string value)
+ {
+ _radioEffect = value;
+ }
+
+ private void OnAnnounceEffectChanged(string value)
+ {
+ _announceEffect = value;
+ }
+
public async Task ConvertTextToSpeech(TTSVoicePrototype voicePrototype, string text, string? effect = null)
{
WantedCount.Inc();
@@ -137,16 +151,14 @@ public sealed class TTSManager
public async Task ConvertTextToSpeechRadio(TTSVoicePrototype voicePrototype, string text)
{
WantedRadioCount.Inc();
- var soundData = await ConvertTextToSpeech(voicePrototype, text, "radio");
-
+ var soundData = await ConvertTextToSpeech(voicePrototype, text, _radioEffect);
return soundData;
}
public async Task ConvertTextToSpeechAnnounce(TTSVoicePrototype voicePrototype, string text)
{
WantedAnnounceCount.Inc();
- var soundData = await ConvertTextToSpeech(voicePrototype, text, "announce");
-
+ var soundData = await ConvertTextToSpeech(voicePrototype, text, _announceEffect);
return soundData;
}
diff --git a/Content.Server/_Sunrise/TTS/TTSSystem.cs b/Content.Server/_Sunrise/TTS/TTSSystem.cs
index 9d8b095c8d..ec2fe1f2ba 100644
--- a/Content.Server/_Sunrise/TTS/TTSSystem.cs
+++ b/Content.Server/_Sunrise/TTS/TTSSystem.cs
@@ -3,8 +3,13 @@ using System.Linq;
using System.Threading.Tasks;
using Content.Server.Chat.Systems;
using Content.Server.GameTicking;
+using Content.Server.Power.Components;
using Content.Shared._Sunrise.SunriseCCVars;
using Content.Shared._Sunrise.TTS;
+using Content.Shared._Sunrise.AnnouncementSpeaker.Components;
+using Content.Shared._Sunrise.AnnouncementSpeaker.Events;
+using Robust.Server.GameObjects;
+using Robust.Shared.Audio;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Configuration;
using Robust.Shared.Player;
@@ -58,6 +63,7 @@ public sealed partial class TTSSystem : EntitySystem
SubscribeLocalEvent(OnEntitySpoke);
SubscribeLocalEvent(OnRadioReceiveEvent);
SubscribeLocalEvent(OnAnnouncementSpoke);
+ SubscribeLocalEvent(OnSpeakerPlayAnnouncement);
SubscribeNetworkEvent(OnRequestPreviewTTS);
SubscribeNetworkEvent(OnClientOptionTTS);
@@ -132,6 +138,12 @@ public sealed partial class TTSSystem : EntitySystem
return;
}
+ // Play announcement sound first if available (for global announcements)
+ if (args.AnnouncementSound != null)
+ {
+ _audioSystem.PlayGlobal(args.AnnouncementSound, args.Source, true);
+ }
+
if (!_isEnabled ||
args.Message.Length > MaxMessageChars * 2 ||
!GetVoicePrototype(args.AnnounceVoice ?? _defaultAnnounceVoice, out var protoVoice))
@@ -139,7 +151,55 @@ public sealed partial class TTSSystem : EntitySystem
var soundData = await GenerateTTS(args.Message, protoVoice, isAnnounce: true);
soundData ??= [];
- RaiseNetworkEvent(new AnnounceTtsEvent(soundData, args.AnnouncementSound), args.Source.RemovePlayers(_ignoredRecipients));
+ RaiseNetworkEvent(new PlayTTSEvent(soundData, null, isRadio: false), args.Source.RemovePlayers(_ignoredRecipients));
+ }
+
+ ///
+ /// Handles TTS generation for speaker-based announcements.
+ /// This is the new system that replaces global broadcast announcements.
+ ///
+ private void OnSpeakerPlayAnnouncement(EntityUid speakerUid, AnnouncementSpeakerComponent component, ref SpeakerPlayAnnouncementEvent args)
+ {
+ // Check if speaker is enabled
+ if (!component.Enabled)
+ return;
+
+ // Check if speaker has power (if required)
+ if (component.RequiresPower)
+ {
+ if (!TryComp(speakerUid, out var powerReceiver) || !powerReceiver.Powered)
+ return;
+ }
+
+ if (!_isEnabled)
+ return;
+
+ byte[]? soundData = args.GeneratedTts;
+ if (soundData == null || soundData.Length == 0)
+ {
+ return;
+ }
+
+ var speakerPos = Transform(speakerUid).Coordinates;
+ var playersInRange = Filter.Empty();
+
+ var playerQuery = EntityQueryEnumerator();
+ while (playerQuery.MoveNext(out var playerUid, out var actor, out var playerTransform))
+ {
+ if (speakerPos.TryDistance(EntityManager, playerTransform.Coordinates, out var distance) &&
+ distance <= component.Range)
+ {
+ playersInRange = playersInRange.AddPlayer(actor.PlayerSession);
+ }
+ }
+
+ // Send TTS to players in range, excluding those who have disabled TTS
+ var filteredPlayers = playersInRange.RemovePlayers(_ignoredRecipients);
+ if (filteredPlayers.Recipients.Any())
+ {
+ var speakerNetEntity = GetNetEntity(speakerUid);
+ RaiseNetworkEvent(new PlayTTSEvent(soundData, speakerNetEntity, isRadio: false), filteredPlayers);
+ }
}
private async void OnEntitySpoke(EntityUid uid, TTSComponent component, EntitySpokeEvent args)
diff --git a/Content.Shared/_Sunrise/AnnouncementSpeaker/Components/AnnouncementSpeakerComponent.cs b/Content.Shared/_Sunrise/AnnouncementSpeaker/Components/AnnouncementSpeakerComponent.cs
new file mode 100644
index 0000000000..fa48a3795c
--- /dev/null
+++ b/Content.Shared/_Sunrise/AnnouncementSpeaker/Components/AnnouncementSpeakerComponent.cs
@@ -0,0 +1,35 @@
+using Robust.Shared.Audio;
+
+namespace Content.Shared._Sunrise.AnnouncementSpeaker.Components;
+
+///
+/// Marks an entity as a speaker that can broadcast station-wide announcements.
+/// Announcements will be played spatially from this speaker with the configured range.
+///
+[RegisterComponent]
+public sealed partial class AnnouncementSpeakerComponent : Component
+{
+ ///
+ /// The range at which this speaker can be heard from.
+ ///
+ [DataField("range")]
+ public float Range = 20f;
+
+ ///
+ /// Whether this speaker is currently enabled.
+ ///
+ [DataField("enabled")]
+ public bool Enabled = true;
+
+ ///
+ /// Volume modifier for announcements played through this speaker.
+ ///
+ [DataField("volumeModifier")]
+ public float VolumeModifier = 1.0f;
+
+ ///
+ /// Whether this speaker requires power to function.
+ ///
+ [DataField("requiresPower")]
+ public bool RequiresPower = true;
+}
\ No newline at end of file
diff --git a/Content.Shared/_Sunrise/AnnouncementSpeaker/Events/AnnouncementSpeakerEvents.cs b/Content.Shared/_Sunrise/AnnouncementSpeaker/Events/AnnouncementSpeakerEvents.cs
new file mode 100644
index 0000000000..da7bd313b0
--- /dev/null
+++ b/Content.Shared/_Sunrise/AnnouncementSpeaker/Events/AnnouncementSpeakerEvents.cs
@@ -0,0 +1,30 @@
+using Content.Shared.Station.Components;
+using Robust.Shared.Audio;
+
+namespace Content.Shared._Sunrise.AnnouncementSpeaker.Events;
+
+///
+/// Event raised when a station-wide announcement should be played through speakers.
+/// This replaces the global broadcast system with a speaker-based network.
+///
+[ByRefEvent]
+public readonly record struct AnnouncementSpeakerEvent(
+ EntityUid Station,
+ string Message,
+ ResolvedSoundSpecifier? AnnouncementSound,
+ string? AnnounceVoice,
+ byte[]? TtsData = null)
+{
+}
+
+///
+/// Event raised on individual speakers to play an announcement.
+///
+[ByRefEvent]
+public readonly record struct SpeakerPlayAnnouncementEvent(
+ string Message,
+ ResolvedSoundSpecifier? AnnouncementSound,
+ string? AnnounceVoice,
+ byte[]? GeneratedTts = null)
+{
+}
diff --git a/Content.Shared/_Sunrise/SunriseCCVars/SunriseCCVars.cs b/Content.Shared/_Sunrise/SunriseCCVars/SunriseCCVars.cs
index e881c9669f..92ba239810 100644
--- a/Content.Shared/_Sunrise/SunriseCCVars/SunriseCCVars.cs
+++ b/Content.Shared/_Sunrise/SunriseCCVars/SunriseCCVars.cs
@@ -57,8 +57,11 @@ public sealed partial class SunriseCCVars : CVars
public static readonly CVarDef TTSRadioVolume =
CVarDef.Create("tts.radio_volume", 0.50f, CVar.CLIENTONLY | CVar.ARCHIVE);
- public static readonly CVarDef TTSAnnounceVolume =
- CVarDef.Create("tts.announce_volume", 0.50f, CVar.CLIENTONLY | CVar.ARCHIVE);
+ public static readonly CVarDef TTSRadioEffect =
+ CVarDef.Create("tts.radio_effect", "radio", CVar.SERVERONLY | CVar.ARCHIVE);
+
+ public static readonly CVarDef TTSAnnounceEffect =
+ CVarDef.Create("tts.announce_effect", "tiny_room", CVar.SERVERONLY | CVar.ARCHIVE);
/**
* Ban Webhook
diff --git a/Content.Shared/_Sunrise/TTS/AnnounceTTSEvent.cs b/Content.Shared/_Sunrise/TTS/AnnounceTTSEvent.cs
deleted file mode 100644
index 21c3955e79..0000000000
--- a/Content.Shared/_Sunrise/TTS/AnnounceTTSEvent.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using Robust.Shared.Audio;
-using Robust.Shared.Serialization;
-
-namespace Content.Shared._Sunrise.TTS;
-
-[Serializable, NetSerializable]
-public sealed class AnnounceTtsEvent(byte[] data, ResolvedSoundSpecifier? announcementSound)
- : EntityEventArgs
-{
- public byte[] Data { get; } = data;
- public ResolvedSoundSpecifier? AnnouncementSound = announcementSound;
-}
diff --git a/Resources/Prototypes/Entities/Structures/Wallmounts/WallmountMachines/intercom.yml b/Resources/Prototypes/Entities/Structures/Wallmounts/WallmountMachines/intercom.yml
index 6d04ada47c..92ca385de2 100644
--- a/Resources/Prototypes/Entities/Structures/Wallmounts/WallmountMachines/intercom.yml
+++ b/Resources/Prototypes/Entities/Structures/Wallmounts/WallmountMachines/intercom.yml
@@ -6,6 +6,10 @@
abstract: true
components:
- type: StationAiWhitelist
+ - type: AnnouncementSpeaker # Sunrise-Edit: Add announcement speaker capability
+ range: 15
+ enabled: true
+ requiresPower: true
- type: Electrified
enabled: false
usesApcPower: true
diff --git a/Resources/Prototypes/Entities/Structures/Wallmounts/WallmountMachines/surveillance_camera.yml b/Resources/Prototypes/Entities/Structures/Wallmounts/WallmountMachines/surveillance_camera.yml
index 561991cd26..14c8afb05b 100644
--- a/Resources/Prototypes/Entities/Structures/Wallmounts/WallmountMachines/surveillance_camera.yml
+++ b/Resources/Prototypes/Entities/Structures/Wallmounts/WallmountMachines/surveillance_camera.yml
@@ -4,6 +4,10 @@
name: camera
description: A surveillance camera. It's watching you. Kinda.
components:
+ - type: AnnouncementSpeaker # Sunrise-Edit: Add announcement speaker capability to cameras
+ range: 12
+ enabled: true
+ requiresPower: true
- type: Physics
bodyType: Static
- type: Fixtures