fish-station/Content.Server/_Sunrise/TTS/TTSSystem.cs
Copilot c39491fe62
Реворк системы оповещений: пространственные динамики с очередью и умной фильтрацией чата (#3000)
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 <vigersray@gmail.com>
2025-08-25 02:47:27 +03:00

349 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Diagnostics.CodeAnalysis;
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;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
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 GameTicker _gameTicker = default!;
private readonly List<string> _sampleText =
new()
{
"Съешь же ещё этих мягких французских булок, да выпей чаю.",
"Клоун, прекрати разбрасывать банановые кожурки офицерам под ноги!",
"Капитан, вы уверены что хотите назначить клоуна на должность главы персонала?",
"Эс Бэ! Тут человек в сером костюме, с тулбоксом и в маске! Помогите!!",
"Учёные, тут странная аномалия в баре! Она уже съела мима!",
"Я надеюсь что инженеры внимательно следят за сингулярностью...",
"Вы слышали эти странные крики в техах? Мне кажется туда ходить небезопасно.",
"Вы не видели Гамлета? Мне кажется он забегал к вам на кухню.",
"Здесь есть доктор? Человек умирает от отравленного пончика! Нужна помощь!",
"Вам нужно согласие и печать квартирмейстера, если вы хотите сделать заказ на партию дробовиков.",
"Возле эвакуационного шаттла разгерметизация! Инженеры, нам срочно нужна ваша помощь!",
"Бармен, налей мне самого крепкого вина, которое есть в твоих запасах!"
};
private const int MaxMessageChars = 100 * 2; // same as SingleBubbleCharLimit * 2
private bool _isEnabled;
private string _defaultAnnounceVoice = "Hanson";
private List<ICommonSession> _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
public override void Initialize()
{
_cfg.OnValueChanged(SunriseCCVars.TTSEnabled, v => _isEnabled = v, true);
SubscribeLocalEvent<TransformSpeechEvent>(OnTransformSpeech);
SubscribeLocalEvent<TTSComponent, EntitySpokeEvent>(OnEntitySpoke);
SubscribeLocalEvent<RadioSpokeEvent>(OnRadioReceiveEvent);
SubscribeLocalEvent<AnnouncementSpokeEvent>(OnAnnouncementSpoke);
SubscribeLocalEvent<AnnouncementSpeakerComponent, SpeakerPlayAnnouncementEvent>(OnSpeakerPlayAnnouncement);
SubscribeNetworkEvent<RequestPreviewTTSEvent>(OnRequestPreviewTTS);
SubscribeNetworkEvent<ClientOptionTTSEvent>(OnClientOptionTTS);
}
private async void OnRequestPreviewTTS(RequestPreviewTTSEvent ev, EntitySessionEventArgs args)
{
if (!_isEnabled ||
!_prototypeManager.TryIndex<TTSVoicePrototype>(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)
return;
var voiceEv = new TransformSpeakerVoiceEvent(args.Source, voiceId);
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);
}
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;
}
private async void OnAnnouncementSpoke(AnnouncementSpokeEvent args)
{
if (!_isEnabled && args.AnnouncementSound != null)
{
var allPlayersInGame = Filter.Empty().AddWhere(_gameTicker.UserHasJoinedGame);
_audioSystem.PlayGlobal(args.AnnouncementSound, allPlayersInGame, true);
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))
return;
var soundData = await GenerateTTS(args.Message, protoVoice, isAnnounce: true);
soundData ??= [];
RaiseNetworkEvent(new PlayTTSEvent(soundData, null, isRadio: false), args.Source.RemovePlayers(_ignoredRecipients));
}
/// <summary>
/// Handles TTS generation for speaker-based announcements.
/// This is the new system that replaces global broadcast announcements.
/// </summary>
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<ApcPowerReceiverComponent>(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<ActorComponent, TransformComponent>();
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)
{
var voiceId = component.VoicePrototypeId;
if (!_isEnabled ||
args.Message.Length > MaxMessageChars ||
voiceId == null)
return;
var voiceEv = new TransformSpeakerVoiceEvent(uid, voiceId);
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);
}
private async void HandleSay(EntityUid uid, string message, TTSVoicePrototype voicePrototype)
{
var recipients = Filter.Pvs(uid, 1F).RemovePlayers(_ignoredRecipients);
// Если нету получаетей ттса то зачем вообще генерировать его?
if (!recipients.Recipients.Any())
return;
var soundData = await GenerateTTS(message, voicePrototype);
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<TransformComponent>();
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)
{
var soundData = await GenerateTTS(message, voicePrototype, isRadio: true);
if (soundData is null)
return;
RaiseNetworkEvent(new PlayTTSEvent(soundData, null, true), Filter.Entities(uids).RemovePlayers(_ignoredRecipients));
}
// ReSharper disable once InconsistentNaming
private async Task<byte[]?> GenerateTTS(string text, TTSVoicePrototype voicePrototype, bool isRadio = false, bool isAnnounce = false)
{
try
{
var textSanitized = Sanitize(text);
if (textSanitized == "") return null;
if (char.IsLetter(textSanitized[^1]))
textSanitized += ".";
if (isRadio)
{
return await _ttsManager.ConvertTextToSpeechRadio(voicePrototype, textSanitized);
}
if (isAnnounce)
{
return await _ttsManager.ConvertTextToSpeechAnnounce(voicePrototype, textSanitized);
}
return await _ttsManager.ConvertTextToSpeech(voicePrototype, textSanitized);
}
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 string VoiceId;
public TransformSpeakerVoiceEvent(EntityUid sender, string voiceId)
{
Sender = sender;
VoiceId = voiceId;
}
}
public sealed class TTSSanitizeEvent : EntityEventArgs
{
public string Text;
public TTSSanitizeEvent(string text)
{
Text = text;
}
}