Реворк системы оповещений: пространственные динамики с очередью и умной фильтрацией чата (#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>
This commit is contained in:
Copilot 2025-08-25 02:47:27 +03:00 committed by GitHub
parent 1bca528c17
commit c39491fe62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 701 additions and 90 deletions

View file

@ -31,7 +31,6 @@
<CheckBox Name="TtsRadioGhostCheckBox" Text="{Loc 'ui-options-tts-radio-ghost-enabled'}" />
<ui:OptionSlider Name="SliderTts" Title="{Loc 'ui-options-tts-volume'}" />
<ui:OptionSlider Name="SliderTtsRadio" Title="{Loc 'ui-options-tts-radio-volume'}" />
<ui:OptionSlider Name="SliderTtsAnnounce" Title="{Loc 'ui-options-tts-announce-volume'}" />
<CheckBox Name="TapePlayerClientCheckBox" Text="{Loc 'ui-options-tape-player-enabled'}" />
<CheckBox Name="JumpSoundDisableCheckBox" Text="{Loc 'ui-options-jump-sound-disable'}" />
<CheckBox Name="VoteMusicDisableCheckBox" Text="{Loc 'ui-options-vote-music-disable'}" />

View file

@ -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);

View file

@ -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<QueuedTts> _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<PlayTTSEvent>(OnPlayTTS);
SubscribeNetworkEvent<AnnounceTtsEvent>(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);
}

View file

@ -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
/// <summary>
/// Dispatches an announcement to all.
/// Dispatches an announcement to all stations through their speaker networks.
/// </summary>
/// <param name="message">The contents of the message</param>
/// <param name="sender">The sender (Communications Console in Communications Console Announcement)</param>
@ -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
/// <summary>
/// Gets all players who have working announcement speakers nearby.
/// Used to filter chat recipients for announcements.
/// </summary>
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;
}
/// <summary>
/// Filters an existing filter to only include players with working speakers nearby.
/// </summary>
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,

View file

@ -75,7 +75,7 @@ namespace Content.Server.Communications
/// In practise this removes the "Sent by ScugMcWawa (Slugcat Captain)" at the bottom of the announcement.
/// </summary>
[DataField]
public bool AnnounceSentBy = true;
public bool AnnounceSentBy = false;
// Sunrise-Start
[DataField("announceVoice", customTypeSerializer:typeof(PrototypeIdSerializer<TTSVoicePrototype>))]

View file

@ -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<TTSComponent>(message.Actor, out var ttsComponent))
{
voice = ttsComponent.VoicePrototypeId;
}
// Sunrise-end
if (comp.Global)
{
// Sunrise-start
var voice = comp.AnnounceVoice;
if (TryComp<TTSComponent>(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}");

View file

@ -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;
/// <summary>
/// Represents a queued announcement waiting to be played.
/// </summary>
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;
}
}
/// <summary>
/// System that manages announcement speakers distributed across stations.
/// Replaces global announcements with spatial audio from speaker networks.
/// </summary>
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<QueuedAnnouncement> _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<AnnouncementSpeakerEvent>(OnAnnouncementSpeaker);
// Note: SpeakerPlayAnnouncementEvent is handled by TTSSystem for the component
}
public override void Update(float frameTime)
{
base.Update(frameTime);
ProcessAnnouncementQueue();
}
/// <summary>
/// Processes the announcement queue and plays the next announcement if none is currently playing.
/// </summary>
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);
}
}
/// <summary>
/// Immediately plays an announcement without queuing.
/// </summary>
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);
}
/// <summary>
/// Handles station-wide announcements by finding all speakers on the station and playing the announcement through them.
/// </summary>
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<AnnouncementSpeakerComponent>(speaker, out var speakerComp))
continue;
// Check if speaker is enabled and has power
if (!speakerComp.Enabled)
continue;
if (speakerComp.RequiresPower)
{
if (!TryComp<ApcPowerReceiverComponent>(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);
}
}
/// <summary>
/// Gets all functional announcement speakers on a station.
/// </summary>
private List<EntityUid> GetStationSpeakers(EntityUid station)
{
var speakers = new List<EntityUid>();
if (!TryComp<StationDataComponent>(station, out var stationData))
return speakers;
// Look through all grids on the station for speakers
foreach (var grid in stationData.Grids)
{
var query = EntityQueryEnumerator<AnnouncementSpeakerComponent, TransformComponent>();
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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
/// <summary>
/// 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.
/// </summary>
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<StationDataComponent>();
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);
}
}
}
/// <summary>
/// Gets a voice prototype by ID, with fallback to default voice.
/// </summary>
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;
}
/// <summary>
/// Generates TTS audio for an announcement with the megaphone effect.
/// </summary>
private async Task<byte[]?> 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<TTSManager>();
return await ttsManager.ConvertTextToSpeechAnnounce(voicePrototype, textSanitized);
}
catch (Exception e)
{
Logger.Error($"TTS System error in announcement generation: {e.Message}");
}
return null;
}
/// <summary>
/// Sanitizes text for TTS generation.
/// </summary>
private string Sanitize(string text)
{
return text.Trim();
}
/// <summary>
/// Checks if a player has any working announcement speakers within range.
/// Used to determine if they should receive announcement messages in chat.
/// </summary>
public bool HasWorkingSpeakersNearby(EntityUid playerEntity)
{
if (!TryComp<TransformComponent>(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<AnnouncementSpeakerComponent, TransformComponent>();
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<ApcPowerReceiverComponent>(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;
}
/// <summary>
/// Gets all speakers on all stations that are working.
/// Used to determine if any announcements can be played at all.
/// </summary>
public bool HasAnyWorkingSpeakers()
{
var query = EntityQueryEnumerator<AnnouncementSpeakerComponent>();
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<ApcPowerReceiverComponent>(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;
}
}

View file

@ -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<byte[]?> ConvertTextToSpeech(TTSVoicePrototype voicePrototype, string text, string? effect = null)
{
WantedCount.Inc();
@ -137,16 +151,14 @@ public sealed class TTSManager
public async Task<byte[]?> 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<byte[]?> ConvertTextToSpeechAnnounce(TTSVoicePrototype voicePrototype, string text)
{
WantedAnnounceCount.Inc();
var soundData = await ConvertTextToSpeech(voicePrototype, text, "announce");
var soundData = await ConvertTextToSpeech(voicePrototype, text, _announceEffect);
return soundData;
}

View file

@ -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<TTSComponent, EntitySpokeEvent>(OnEntitySpoke);
SubscribeLocalEvent<RadioSpokeEvent>(OnRadioReceiveEvent);
SubscribeLocalEvent<AnnouncementSpokeEvent>(OnAnnouncementSpoke);
SubscribeLocalEvent<AnnouncementSpeakerComponent, SpeakerPlayAnnouncementEvent>(OnSpeakerPlayAnnouncement);
SubscribeNetworkEvent<RequestPreviewTTSEvent>(OnRequestPreviewTTS);
SubscribeNetworkEvent<ClientOptionTTSEvent>(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));
}
/// <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)

View file

@ -0,0 +1,35 @@
using Robust.Shared.Audio;
namespace Content.Shared._Sunrise.AnnouncementSpeaker.Components;
/// <summary>
/// Marks an entity as a speaker that can broadcast station-wide announcements.
/// Announcements will be played spatially from this speaker with the configured range.
/// </summary>
[RegisterComponent]
public sealed partial class AnnouncementSpeakerComponent : Component
{
/// <summary>
/// The range at which this speaker can be heard from.
/// </summary>
[DataField("range")]
public float Range = 20f;
/// <summary>
/// Whether this speaker is currently enabled.
/// </summary>
[DataField("enabled")]
public bool Enabled = true;
/// <summary>
/// Volume modifier for announcements played through this speaker.
/// </summary>
[DataField("volumeModifier")]
public float VolumeModifier = 1.0f;
/// <summary>
/// Whether this speaker requires power to function.
/// </summary>
[DataField("requiresPower")]
public bool RequiresPower = true;
}

View file

@ -0,0 +1,30 @@
using Content.Shared.Station.Components;
using Robust.Shared.Audio;
namespace Content.Shared._Sunrise.AnnouncementSpeaker.Events;
/// <summary>
/// Event raised when a station-wide announcement should be played through speakers.
/// This replaces the global broadcast system with a speaker-based network.
/// </summary>
[ByRefEvent]
public readonly record struct AnnouncementSpeakerEvent(
EntityUid Station,
string Message,
ResolvedSoundSpecifier? AnnouncementSound,
string? AnnounceVoice,
byte[]? TtsData = null)
{
}
/// <summary>
/// Event raised on individual speakers to play an announcement.
/// </summary>
[ByRefEvent]
public readonly record struct SpeakerPlayAnnouncementEvent(
string Message,
ResolvedSoundSpecifier? AnnouncementSound,
string? AnnounceVoice,
byte[]? GeneratedTts = null)
{
}

View file

@ -57,8 +57,11 @@ public sealed partial class SunriseCCVars : CVars
public static readonly CVarDef<float> TTSRadioVolume =
CVarDef.Create("tts.radio_volume", 0.50f, CVar.CLIENTONLY | CVar.ARCHIVE);
public static readonly CVarDef<float> TTSAnnounceVolume =
CVarDef.Create("tts.announce_volume", 0.50f, CVar.CLIENTONLY | CVar.ARCHIVE);
public static readonly CVarDef<string> TTSRadioEffect =
CVarDef.Create("tts.radio_effect", "radio", CVar.SERVERONLY | CVar.ARCHIVE);
public static readonly CVarDef<string> TTSAnnounceEffect =
CVarDef.Create("tts.announce_effect", "tiny_room", CVar.SERVERONLY | CVar.ARCHIVE);
/**
* Ban Webhook

View file

@ -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;
}

View file

@ -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

View file

@ -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