using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; using Content.Server._Sunrise.AnnouncementSpeaker; using Content.Server.Chat.Systems; using Content.Server.GameTicking; using Content.Server.Power.Components; using Content.Shared._Sunrise.CollectiveMind; 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 Content.Shared.Silicons.Borgs.Components; using Robust.Shared.Audio.Systems; using Robust.Shared.Configuration; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Random; using Content.Shared.Chat; namespace Content.Server._Sunrise.TTS; // ReSharper disable once InconsistentNaming public sealed partial class TTSSystem : EntitySystem { [Dependency] private readonly IConfigurationManager _cfg = default!; [Dependency] private readonly IPrototypeManager _prototypeManager = default!; [Dependency] private readonly TTSManager _ttsManager = default!; [Dependency] private readonly SharedTransformSystem _xforms = default!; [Dependency] private readonly IRobustRandom _rng = default!; [Dependency] private readonly SharedAudioSystem _audioSystem = default!; [Dependency] private readonly AnnouncementSpeakerSystem _announcementSpeakerSystem = default!; private readonly List _sampleText = new() { "Съешь же ещё этих мягких французских булок, да выпей чаю.", "Клоун, прекрати разбрасывать банановые кожурки офицерам под ноги!", "Капитан, вы уверены что хотите назначить клоуна на должность главы персонала?", "Эс Бэ! Тут человек в сером костюме, с тулбоксом и в маске! Помогите!!", "Учёные, тут странная аномалия в баре! Она уже съела мима!", "Я надеюсь что инженеры внимательно следят за сингулярностью...", "Вы слышали эти странные крики в техах? Мне кажется туда ходить небезопасно.", "Вы не видели Гамлета? Мне кажется он забегал к вам на кухню.", "Здесь есть доктор? Человек умирает от отравленного пончика! Нужна помощь!", "Вам нужно согласие и печать квартирмейстера, если вы хотите сделать заказ на партию дробовиков.", "Возле эвакуационного шаттла разгерметизация! Инженеры, нам срочно нужна ваша помощь!", "Бармен, налей мне самого крепкого вина, которое есть в твоих запасах!" }; private const int MaxMessageChars = 100 * 2; // same as SingleBubbleCharLimit * 2 private bool _isEnabled; private string _defaultAnnounceVoice = "Hanson"; private List _ignoredRecipients = new(); private const float WhisperVoiceVolumeModifier = 0.6f; // how far whisper goes in world units private const int WhisperVoiceRange = 3; // how far whisper goes in world units private string _radioEffect = string.Empty; public override void Initialize() { _cfg.OnValueChanged(SunriseCCVars.TTSEnabled, v => _isEnabled = v, true); _cfg.OnValueChanged(SunriseCCVars.TTSRadioEffect, OnRadioEffectChanged, true); SubscribeLocalEvent(OnTransformSpeech); SubscribeLocalEvent(OnEntitySpoke); SubscribeLocalEvent(OnRadioReceiveEvent); SubscribeLocalEvent(OnCollectiveMindSpokeEvent); SubscribeLocalEvent(OnAnnouncementSpeaker); SubscribeNetworkEvent(OnRequestPreviewTTS); SubscribeNetworkEvent(OnClientOptionTTS); } private void OnRadioEffectChanged(string value) { _radioEffect = value; } private async void OnRequestPreviewTTS(RequestPreviewTTSEvent ev, EntitySessionEventArgs args) { if (!_isEnabled || !_prototypeManager.TryIndex(ev.VoiceId, out var protoVoice)) return; var previewText = _rng.Pick(_sampleText); var soundData = await GenerateTTS(previewText, protoVoice); if (soundData is null) return; RaiseNetworkEvent(new PlayTTSEvent(soundData), Filter.SinglePlayer(args.SenderSession)); } private async void OnClientOptionTTS(ClientOptionTTSEvent ev, EntitySessionEventArgs args) { if (ev.Enabled) _ignoredRecipients.Remove(args.SenderSession); else _ignoredRecipients.Add(args.SenderSession); } private void OnRadioReceiveEvent(RadioSpokeEvent args) { if (!_isEnabled || args.Message.Length > MaxMessageChars) return; if (!TryComp(args.Source, out TTSComponent? senderComponent)) return; var voiceId = senderComponent.VoicePrototypeId; if (voiceId == null || string.IsNullOrWhiteSpace(voiceId.Value)) return; var voiceEv = new TransformSpeakerVoiceEvent(args.Source, voiceId.Value); RaiseLocalEvent(args.Source, voiceEv); voiceId = voiceEv.VoiceId; if (!GetVoicePrototype(voiceId, out var protoVoice)) { return; } var accentEvent = new TTSSanitizeEvent(args.Message); RaiseLocalEvent(args.Source, accentEvent); var message = accentEvent.Text; HandleRadio(args.Receivers, message, protoVoice, voiceEv.Effect); } private async void OnCollectiveMindSpokeEvent(CollectiveMindSpokeEvent args) { if (!_isEnabled || args.Message.Length > MaxMessageChars) return; // Get the collective mind prototype to use its voice if (!_prototypeManager.TryIndex(args.CollectiveMindId, out var collectiveMindProto)) return; var voiceId = collectiveMindProto.VoiceId; if (voiceId == null || string.IsNullOrWhiteSpace(voiceId.Value)) return; if (!GetVoicePrototype(voiceId, out var protoVoice)) { return; } var accentEvent = new TTSSanitizeEvent(args.Message); RaiseLocalEvent(args.Source, accentEvent); var message = accentEvent.Text; var soundData = await GenerateTTS(message, protoVoice); if (soundData is null) return; var recipients = Filter.Entities(args.Receivers.ToArray()).RemovePlayers(_ignoredRecipients); RaiseNetworkEvent(new PlayTTSEvent(soundData, null, false), recipients); } private bool GetVoicePrototype(ProtoId? voiceId, [NotNullWhen(true)] out TTSVoicePrototype? voicePrototype) { if (!_prototypeManager.TryIndex(voiceId, out voicePrototype)) { return _prototypeManager.TryIndex(_defaultAnnounceVoice, out voicePrototype); } return true; } /// /// 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 = _announcementSpeakerSystem.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); } } if (ev.TtsData == null || ev.TtsData.Length <= 0) return; var speakerData = new List<(EntityUid Uid, AnnouncementSpeakerComponent Comp)>(); foreach (var speaker in speakers) { if (!TryComp(speaker, out var speakerComp)) continue; if (!speakerComp.Enabled) continue; if (speakerComp.RequiresPower) { if (!TryComp(speaker, out var powerReceiver) || !powerReceiver.Powered) continue; } speakerData.Add((speaker, speakerComp)); } // Для каждого игрока на станции определяем, какие динамики он слышит var playerQuery = EntityQueryEnumerator(); while (playerQuery.MoveNext(out var playerUid, out var actor, out var playerXform)) { if (_ignoredRecipients.Contains(actor.PlayerSession)) continue; var heardSpeakers = new List(); foreach (var (speakerUid, speakerComp) in speakerData) { if (Transform(speakerUid).Coordinates.TryDistance(EntityManager, playerXform.Coordinates, out var dist) && dist <= speakerComp.Range) { heardSpeakers.Add(GetNetEntity(speakerUid)); } } if (heardSpeakers.Count > 0) { var evMulti = new PlayMultiSpeakerTTSEvent(heardSpeakers, ev.TtsData); RaiseNetworkEvent(evMulti, actor.PlayerSession); } } } private async void OnEntitySpoke(EntityUid uid, TTSComponent component, EntitySpokeEvent args) { var voiceId = component.VoicePrototypeId; if (!_isEnabled || args.Message.Length > MaxMessageChars || voiceId == null || string.IsNullOrWhiteSpace(voiceId.Value)) return; var voiceEv = new TransformSpeakerVoiceEvent(uid, voiceId.Value); RaiseLocalEvent(uid, voiceEv); voiceId = voiceEv.VoiceId; if (!GetVoicePrototype(voiceId, out var protoVoice)) { return; } var accentEvent = new TTSSanitizeEvent(args.Message); RaiseLocalEvent(uid, accentEvent); var message = accentEvent.Text; if (args.ObfuscatedMessage != null) { HandleWhisper(uid, message, protoVoice); return; } HandleSay(uid, message, protoVoice, voiceEv.Effect); } private async void HandleSay(EntityUid uid, string message, TTSVoicePrototype voicePrototype, string? effect) { var recipients = Filter.Pvs(uid, 1F).RemovePlayers(_ignoredRecipients); // Если нету получаетей ттса то зачем вообще генерировать его? if (!recipients.Recipients.Any()) return; var soundData = await GenerateTTS(message, voicePrototype, effect); if (soundData is null) return; var netEntity = GetNetEntity(uid); RaiseNetworkEvent(new PlayTTSEvent(soundData, netEntity), recipients); } private async void HandleWhisper(EntityUid uid, string message, TTSVoicePrototype voicePrototype) { // If it's a whisper into a radio, generate speech without whisper // attributes to prevent an additional speech synthesis event var soundData = await GenerateTTS(message, voicePrototype); if (soundData is null) return; // TODO: Check obstacles var xformQuery = GetEntityQuery(); var sourcePos = _xforms.GetWorldPosition(xformQuery.GetComponent(uid), xformQuery); var receptions = Filter.Pvs(uid).Recipients; foreach (var session in receptions) { if (!session.AttachedEntity.HasValue) continue; if (_ignoredRecipients.Contains(session)) return; var xform = xformQuery.GetComponent(session.AttachedEntity.Value); var distance = (sourcePos - _xforms.GetWorldPosition(xform, xformQuery)).LengthSquared(); if (distance > WhisperVoiceRange) continue; var ttsEvent = new PlayTTSEvent( soundData, GetNetEntity(uid), false, WhisperVoiceVolumeModifier * (1f - distance / WhisperVoiceRange)); RaiseNetworkEvent(ttsEvent, session); } } private async void HandleRadio(EntityUid[] uids, string message, TTSVoicePrototype voicePrototype, string? effect = null) { var soundData = await GenerateTTS(message, voicePrototype, _radioEffect); if (soundData is null) return; RaiseNetworkEvent(new PlayTTSEvent(soundData, null, true), Filter.Entities(uids).RemovePlayers(_ignoredRecipients)); } // ReSharper disable once InconsistentNaming public async Task GenerateTTS(string text, TTSVoicePrototype voicePrototype, string? effect = null) { try { var textSanitized = Sanitize(text); if (textSanitized == "") return null; if (char.IsLetter(textSanitized[^1])) textSanitized += "."; return await _ttsManager.ConvertTextToSpeech(voicePrototype, textSanitized, effect); } catch (Exception e) { // Catch TTS exceptions to prevent a server crash. Logger.Error($"TTS System error: {e.Message}"); } return null; } } public sealed class TransformSpeakerVoiceEvent : EntityEventArgs { public EntityUid Sender; public ProtoId VoiceId; public string? Effect; public TransformSpeakerVoiceEvent(EntityUid sender, ProtoId voiceId, string? effect = null) { Sender = sender; VoiceId = voiceId; Effect = effect; } } public sealed class TTSSanitizeEvent : EntityEventArgs { public string Text; public TTSSanitizeEvent(string text) { Text = text; } }