using Content.Shared.Chat;
using Content.Shared.Corvax.CCCVars;
using Content.Shared.Corvax.TTS;
using Content.Shared.GameTicking;
using Robust.Client.Audio;
using Robust.Client.ResourceManagement;
using Robust.Shared.Audio;
using Robust.Shared.Audio.Components;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Configuration;
using Robust.Shared.ContentPack;
using Robust.Shared.Random;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Client.Corvax.TTS;
///
/// Plays TTS audio in world
///
// ReSharper disable once InconsistentNaming
public sealed partial class TTSSystem : EntitySystem
{
[Dependency] private IConfigurationManager _cfg = default!;
[Dependency] private IResourceManager _res = default!;
[Dependency] private IRobustRandom _ran = default!;
[Dependency] private AudioSystem _audio = default!;
private ISawmill _sawmill = default!;
private static MemoryContentRoot _contentRoot = new();
private static readonly ResPath Prefix = ResPath.Root / "TTS";
private static bool _contentRootAdded;
private const float WhisperFade = 4f;
private const float MinimalVolume = -10f;
private const float GlobalVolumeBonus = 1.5f;
private const float RadioPitchMin = 0.95f;
private const float RadioPitchMax = 1.02f;
private const float RadioVariationMin = 0.005f;
private const float RadioVariationMax = 0.025f;
private const float RadioRolloffMin = 1.5f;
private const float RadioRolloffMax = 2.5f;
private const float PlaybackDelay = 0.8f;
private float _lastRadioPitch = 0.98f;
private float _radioVolume = 1.2f;
private float _volume = 1.2f;
private readonly HashSet _playingEntities = new();
private readonly Dictionary> _entityQueues = new();
private TTSVoiceEffectPreset _voiceEffectPreset = TTSVoiceEffectPreset.None;
private bool _ttsEnabled;
private int _fileIdx;
public override void Initialize()
{
base.Initialize();
if (!_contentRootAdded)
{
_contentRootAdded = true;
_res.AddRoot(Prefix, _contentRoot);
}
_sawmill = Logger.GetSawmill("tts");
_cfg.OnValueChanged(CCCVars.TTSEnabled, OnTtsEnabledChanged, true);
_cfg.OnValueChanged(CCCVars.TTSVoiceEffect, OnVoiceEffectChanged, true);
_cfg.OnValueChanged(CCCVars.TTSRadioVolume, OnRadioVolumeChanged, true);
_cfg.OnValueChanged(CCCVars.TTSVolume, OnVolumeChanged, true);
}
public override void Shutdown()
{
base.Shutdown();
_cfg.UnsubValueChanged(CCCVars.TTSEnabled, OnTtsEnabledChanged);
_cfg.UnsubValueChanged(CCCVars.TTSVoiceEffect, OnVoiceEffectChanged);
_cfg.UnsubValueChanged(CCCVars.TTSRadioVolume, OnRadioVolumeChanged);
_cfg.UnsubValueChanged(CCCVars.TTSVolume, OnVolumeChanged);
_entityQueues.Clear();
_playingEntities.Clear();
ShutdownEffects();
}
private void OnTtsEnabledChanged(bool value)
{
_ttsEnabled = value;
if (!value)
{
ShutdownEffects();
}
}
private void OnVoiceEffectChanged(int newValue)
{
_voiceEffectPreset = (TTSVoiceEffectPreset)newValue;
ShutdownVoiceEffect();
}
private void OnVolumeChanged(float value)
{
_volume = value;
}
private void OnRadioVolumeChanged(float value)
{
_radioVolume = value;
}
[SubscribeNetworkEvent]
private void OnRoundRestartCleanup(RoundRestartCleanupEvent ev)
{
_entityQueues.Clear();
_playingEntities.Clear();
ShutdownEffects();
}
public void RequestPreviewTTS(string voiceId)
{
RaiseNetworkEvent(new RequestPreviewTTSEvent(voiceId));
}
[SubscribeNetworkEvent]
private void OnPlayTTS(PlayTTSEvent ev)
{
if (!_ttsEnabled)
return;
// It will stop clogging up your memory if you turn off one of the sliders to 0
if (ev.IsRadio && _radioVolume <= 0)
{
_sawmill.Verbose("Radio TTS volume zero, skipping playback");
return;
}
if (_volume <= 0)
{
_sawmill.Verbose("TTS volume zero, skipping playback");
return;
}
if (ev.SourceUid == null)
{
PlayTTSInternal(ev);
return;
}
var sourceUid = ev.SourceUid.Value;
lock (_entityQueues)
{
if (!_entityQueues.TryGetValue(sourceUid, out var queue))
{
queue = new Queue();
_entityQueues[sourceUid] = queue;
}
if (queue.Count >= 6)
{
_sawmill.Verbose($"TTS queue for {sourceUid} is full, dropping old message");
queue.Dequeue();
queue.Enqueue(ev);
return;
}
queue.Enqueue(ev);
_sawmill.Verbose($"TTS added to queue for entity {sourceUid}. Queue size: {queue.Count}");
}
if (!_playingEntities.Contains(sourceUid))
{
ProcessNextInQueueForEntity(sourceUid);
}
}
private void ProcessNextInQueueForEntity(NetEntity entityUid)
{
PlayTTSEvent? ev = null;
lock (_entityQueues)
{
if (_entityQueues.TryGetValue(entityUid, out var queue) && queue.Count > 0)
{
ev = queue.Dequeue();
_playingEntities.Add(entityUid);
}
else
{
_playingEntities.Remove(entityUid);
if (queue != null && queue.Count == 0)
_entityQueues.Remove(entityUid);
return;
}
}
try
{
PlayTTSInternal(ev,
() =>
{
_playingEntities.Remove(entityUid);
ProcessNextInQueueForEntity(entityUid);
});
}
catch (Exception ex)
{
_sawmill.Error($"Error playing TTS for entity {entityUid}: {ex.Message}");
_playingEntities.Remove(entityUid);
ProcessNextInQueueForEntity(entityUid);
}
}
private void PlayTTSInternal(PlayTTSEvent ev, Action? onComplete = null)
{
var filePath = new ResPath($"{_fileIdx++}.ogg");
_contentRoot.AddOrUpdateFile(filePath, ev.Data);
using var audioResource = new AudioResource();
audioResource.Load(IoCManager.Instance!, Prefix / filePath);
var audioParams = AudioParams.Default
.WithVolume(AdjustVolume(ev.SourceUid == null, ev.IsWhisper, ev.IsRadio))
.WithMaxDistance(AdjustDistance(ev.IsWhisper));
var soundSpecifier = new ResolvedPathSpecifier(Prefix / filePath);
try
{
if (ev.IsRadio)
{
var pitch = GetRadioPitch();
var variation = GetRadioVariation();
var rolloff = GetRadioRolloff();
var radioParams = audioParams
.WithRolloffFactor(rolloff)
.WithVariation(variation)
.WithPitchScale(pitch);
PlayRadioWithEffectInternal(audioResource, soundSpecifier, radioParams);
}
else
{
Entity? audioResult;
if (ev.SourceUid != null)
{
var sourceUid = GetEntity(ev.SourceUid.Value);
if (TerminatingOrDeleted(sourceUid))
{
onComplete?.Invoke();
return;
}
audioResult = _audio.PlayEntity(audioResource.AudioStream, sourceUid, soundSpecifier, audioParams);
}
else
{
audioResult = _audio.PlayGlobal(audioResource.AudioStream, soundSpecifier, audioParams);
}
if (audioResult != null && _voiceEffectPreset != 0)
{
ApplyVoiceEffect(audioResult.Value, _voiceEffectPreset);
}
}
}
finally
{
_contentRoot.RemoveFile(filePath);
}
var duration = audioResource.AudioStream?.Length ?? TimeSpan.Zero;
var delay = duration + TimeSpan.FromSeconds(PlaybackDelay);
Timer.Spawn(delay,
() =>
{
onComplete?.Invoke();
});
}
private void PlayRadioWithEffectInternal(
AudioResource audioResource,
ResolvedPathSpecifier soundSpecifier,
AudioParams audioParams)
{
var audioResult = _audio.PlayGlobal(audioResource.AudioStream, soundSpecifier, audioParams);
if (audioResult == null)
return;
ApplyRadioEffect(audioResult.Value);
var secondParams = audioParams
.WithPitchScale(audioParams.Pitch * (1 + _ran.NextFloat(0.02f, 0.05f)))
.WithVolume(audioParams.Volume - 4f)
.WithVariation(0.04f);
_audio.PlayGlobal(audioResource.AudioStream, soundSpecifier, secondParams);
}
#region Utility Methods
private float AdjustVolume(bool isGlobal, bool isWhisper, bool isRadio)
{
var volume = isRadio
? MinimalVolume + SharedAudioSystem.GainToVolume(_radioVolume)
: MinimalVolume + SharedAudioSystem.GainToVolume(_volume);
if (isGlobal)
return volume + SharedAudioSystem.GainToVolume(GlobalVolumeBonus);
if (isWhisper)
{
var fade = isRadio ? WhisperFade * 0.15f : WhisperFade;
volume -= SharedAudioSystem.GainToVolume(fade);
}
return volume;
}
private static float AdjustDistance(bool isWhisper)
{
return isWhisper ? SharedChatSystem.WhisperMuffledRange : SharedChatSystem.VoiceRange;
}
private float GetRadioPitch()
{
var target = _ran.NextFloat(RadioPitchMin, RadioPitchMax);
_lastRadioPitch = _lastRadioPitch * 0.7f + target * 0.3f;
return _lastRadioPitch;
}
private float GetRadioVariation()
{
return _ran.NextFloat(RadioVariationMin, RadioVariationMax);
}
private float GetRadioRolloff()
{
return _ran.NextFloat(RadioRolloffMin, RadioRolloffMax);
}
#endregion
}