From c39491fe62bef4953f842a9a34ba01351ef29d9c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 Aug 2025 02:47:27 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B2=D0=BE=D1=80=D0=BA=20=D1=81?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D1=8B=20=D0=BE=D0=BF=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=89=D0=B5=D0=BD=D0=B8=D0=B9:=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D0=BD=D1=81=D1=82=D0=B2=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=B4=D0=B8=D0=BD=D0=B0=D0=BC=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=81=20=D0=BE=D1=87=D0=B5=D1=80=D0=B5=D0=B4=D1=8C?= =?UTF-8?q?=D1=8E=20=D0=B8=20=D1=83=D0=BC=D0=BD=D0=BE=D0=B9=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D1=80=D0=B0=D1=86=D0=B8=D0=B5=D0=B9=20=D1=87?= =?UTF-8?q?=D0=B0=D1=82=D0=B0=20(#3000)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: VigersRay <60344369+VigersRay@users.noreply.github.com> Co-authored-by: Vigers Ray --- Content.Client/Options/UI/Tabs/ExtraTab.xaml | 1 - .../Options/UI/Tabs/ExtraTab.xaml.cs | 5 - Content.Client/_Sunrise/TTS/TTSSystem.cs | 29 +- Content.Server/Chat/Systems/ChatSystem.cs | 149 +++++-- .../CommunicationsConsoleComponent.cs | 2 +- .../CommunicationsConsoleSystem.cs | 16 +- .../AnnouncementSpeakerSystem.cs | 415 ++++++++++++++++++ Content.Server/_Sunrise/TTS/TTSManager.cs | 20 +- Content.Server/_Sunrise/TTS/TTSSystem.cs | 62 ++- .../AnnouncementSpeakerComponent.cs | 35 ++ .../Events/AnnouncementSpeakerEvents.cs | 30 ++ .../_Sunrise/SunriseCCVars/SunriseCCVars.cs | 7 +- .../_Sunrise/TTS/AnnounceTTSEvent.cs | 12 - .../Wallmounts/WallmountMachines/intercom.yml | 4 + .../WallmountMachines/surveillance_camera.yml | 4 + 15 files changed, 701 insertions(+), 90 deletions(-) create mode 100644 Content.Server/_Sunrise/AnnouncementSpeaker/AnnouncementSpeakerSystem.cs create mode 100644 Content.Shared/_Sunrise/AnnouncementSpeaker/Components/AnnouncementSpeakerComponent.cs create mode 100644 Content.Shared/_Sunrise/AnnouncementSpeaker/Events/AnnouncementSpeakerEvents.cs delete mode 100644 Content.Shared/_Sunrise/TTS/AnnounceTTSEvent.cs 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