diff --git a/Content.Client/Entry/EntryPoint.cs b/Content.Client/Entry/EntryPoint.cs index b9c7ab5b7d..bf5f021be3 100644 --- a/Content.Client/Entry/EntryPoint.cs +++ b/Content.Client/Entry/EntryPoint.cs @@ -1,4 +1,3 @@ -using Content.Client._White.TTS; using Content.Client.Administration.Managers; using Content.Client.Changelog; using Content.Client.Chat.Managers; @@ -73,7 +72,6 @@ public sealed class EntryPoint : GameClient [Dependency] private readonly ILogManager _logManager = default!; [Dependency] private readonly JoinQueueManager _joinQueue = default!; [Dependency] private readonly DiscordAuthManager _discordAuth = default!; - [Dependency] private readonly TTSManager _ttsManager = default!; // WD EDIT public override void Init() { @@ -168,7 +166,6 @@ public override void PostInit() _documentParsingManager.Initialize(); _joinQueue.Initialize(); _discordAuth.Initialize(); - _ttsManager.Initialize(); // WD EDIT _baseClient.RunLevelChanged += (_, args) => { diff --git a/Content.Client/IoC/ClientContentIoC.cs b/Content.Client/IoC/ClientContentIoC.cs index df3833eb60..80c04cda04 100644 --- a/Content.Client/IoC/ClientContentIoC.cs +++ b/Content.Client/IoC/ClientContentIoC.cs @@ -1,4 +1,3 @@ -using Content.Client._White.TTS; using Content.Client.Administration.Managers; using Content.Client.Changelog; using Content.Client.Chat.Managers; @@ -53,7 +52,6 @@ public static void Register() collection.Register(); IoCManager.Register(); IoCManager.Register(); - IoCManager.Register(); // WD EDIT } } } diff --git a/Content.Client/Options/UI/Tabs/AudioTab.xaml.cs b/Content.Client/Options/UI/Tabs/AudioTab.xaml.cs index ecab7ed010..e36f6ed095 100644 --- a/Content.Client/Options/UI/Tabs/AudioTab.xaml.cs +++ b/Content.Client/Options/UI/Tabs/AudioTab.xaml.cs @@ -187,6 +187,7 @@ private void UpdateChanges() var isTtsVolumeSame = Math.Abs(TtsVolumeSlider.Value - _cfg.GetCVar(WhiteCVars.TTSVolume) * 100f / ContentAudioSystem.TTSMultiplier) < 0.01f; // WD EDIT + var isAmbientSoundsSame = (int)AmbienceSoundsSlider.Value == _cfg.GetCVar(CCVars.MaxAmbientSources); var isLobbySame = LobbyMusicCheckBox.Pressed == _cfg.GetCVar(CCVars.LobbyMusicEnabled); var isRestartSoundsSame = RestartSoundsCheckBox.Pressed == _cfg.GetCVar(CCVars.RestartSoundsEnabled); diff --git a/Content.Client/_White/TTS/AnnounceTTSSystem.cs b/Content.Client/_White/TTS/AnnounceTTSSystem.cs new file mode 100644 index 0000000000..4191a76664 --- /dev/null +++ b/Content.Client/_White/TTS/AnnounceTTSSystem.cs @@ -0,0 +1,194 @@ +using System.Diagnostics.CodeAnalysis; +using Content.Shared._White.TTS; +using Content.Shared.CCVar; +using Content.Shared.GameTicking; +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.Player; +using Robust.Shared.Utility; + +namespace Content.Client._White.TTS; + +// ReSharper disable once InconsistentNaming +public sealed class AnnounceTTSSystem : EntitySystem +{ + [Dependency] private readonly SharedAudioSystem _audio = default!; + [Dependency] private readonly IConfigurationManager _cfg = default!; + [Dependency] private readonly IResourceCache _resourceCache = default!; + + private ISawmill _sawmill = default!; + private readonly MemoryContentRoot _contentRoot = new(); + private ResPath _prefix; + + private float _volume = 0.0f; + private ulong _fileIdx = 0; + private static ulong _shareIdx = 0; + + private TTSAudioStream? _currentlyPlaying; + private readonly Queue _queuedStreams = new(); + + /// + public override void Initialize() + { + _prefix = ResPath.Root / $"TTSAnon{_shareIdx++}"; + _resourceCache.AddRoot(_prefix, _contentRoot); + _sawmill = Logger.GetSawmill("AnnounceTTSSystem"); + _cfg.OnValueChanged(CCVars.AnnouncerVolume, OnTtsVolumeChanged, true); + SubscribeNetworkEvent(OnAnnounceTTSPlay); + SubscribeNetworkEvent(OnCleanup); + } + + private void OnCleanup(RoundRestartCleanupEvent ev) + { + EndStreams(); + _contentRoot.Clear(); + } + + /// + public override void FrameUpdate(float frameTime) + { + if (_queuedStreams.Count == 0) + return; + + var isDoNext = true; + try + { + isDoNext = _currentlyPlaying == null || + (_currentlyPlaying.AudioStream != null && TerminatingOrDeleted(_currentlyPlaying.AudioStream!.Value)) + || !(_currentlyPlaying.AudioStream?.Comp.Playing ?? false); + } + catch (Exception err) + { + isDoNext = true; + } + + if (isDoNext) + { + _currentlyPlaying?.StopAndClean(this); + ProcessEntityQueue(); + } + + } + + /// + public override void Shutdown() + { + _cfg.UnsubValueChanged(CCVars.AnnouncerVolume, OnTtsVolumeChanged); + EndStreams(); + _contentRoot.Dispose(); + } + + private void OnAnnounceTTSPlay(AnnounceTTSEvent ev) + { + var volume = Math.Max(-5f, SharedAudioSystem.GainToVolume(_volume)); + + + var file = new ResPath(ev.AnnouncementSound); + + if (!_resourceCache.TryGetResource(file, out var audio)) + { + _sawmill.Error($"Server tried to play audio file {ev.AnnouncementSound} which does not exist."); + return; + } + + if (TryCreateAudioSource(file, ev.AnnouncementParams.Volume, out var sourceAnnounce)) + AddEntityStreamToQueue(sourceAnnounce); + if (ev.Data.Length > 0 && TryCreateAudioSource(ev.Data, volume, out var source)) + { + source.DelayMs = (int) audio.AudioStream.Length.TotalMilliseconds; + AddEntityStreamToQueue(source); + } + + } + + private void AddEntityStreamToQueue(TTSAudioStream stream) + { + _queuedStreams.Enqueue(stream); + } + + private void ProcessEntityQueue() + { + if (_queuedStreams.TryDequeue(out _currentlyPlaying)) + PlayEntity(_currentlyPlaying); + } + + private bool TryCreateAudioSource(byte[] data, float volume, [NotNullWhen(true)] out TTSAudioStream? source) + { + var filePath = new ResPath($"{_fileIdx++}.ogg"); + _contentRoot.AddOrUpdateFile(filePath, data); + + var audioParams = AudioParams.Default.WithVolume(volume).WithRolloffFactor(1f).WithMaxDistance(float.MaxValue).WithReferenceDistance(1f); + var soundPath = new SoundPathSpecifier(_prefix / filePath, audioParams); + + source = new TTSAudioStream(soundPath, filePath); + + return true; + } + + private bool TryCreateAudioSource(ResPath audio, float volume, + [NotNullWhen(true)] out TTSAudioStream? source) + { + var audioParams = AudioParams.Default.WithVolume(volume).WithRolloffFactor(1f).WithMaxDistance(float.MaxValue).WithReferenceDistance(1f); + + var soundPath = new SoundPathSpecifier(audio, audioParams); + + + source = new TTSAudioStream(soundPath, null); + + return true; + } + + private void PlayEntity(TTSAudioStream stream) + { + stream.AudioStream = _audio.PlayGlobal(stream.Source, Filter.Local(), false); + } + + private void OnTtsVolumeChanged(float volume) + { + _volume = volume; + } + + private void EndStreams() + { + foreach (var stream in _queuedStreams) + { + stream.StopAndClean(this); + } + + _queuedStreams.Clear(); + } + + // ReSharper disable once InconsistentNaming + private sealed class TTSAudioStream + { + public SoundPathSpecifier Source { get; } + public ResPath? CacheFile { get; } + public Entity? AudioStream { get; set; } + + public int DelayMs { get; set; } + + public TTSAudioStream(SoundPathSpecifier source, ResPath? cacheFile, int delayMs = 0) + { + Source = source; + CacheFile = cacheFile; + DelayMs = delayMs; + } + + public void StopAndClean(AnnounceTTSSystem sys) + { + if (AudioStream != null) + { + sys._audio.Stop(AudioStream.Value,AudioStream.Value); + + } + if (CacheFile != null) + { + sys._contentRoot.RemoveFile(CacheFile.Value); + } + } + } +} diff --git a/Content.Client/_White/TTS/HumanoidProfileEditor.TTS.cs b/Content.Client/_White/TTS/HumanoidProfileEditor.TTS.cs index c4ad69ca98..d4606b4faa 100644 --- a/Content.Client/_White/TTS/HumanoidProfileEditor.TTS.cs +++ b/Content.Client/_White/TTS/HumanoidProfileEditor.TTS.cs @@ -1,8 +1,7 @@ using System.Linq; using Content.Client._White.TTS; -using Content.Shared.Preferences; using Content.Shared._White.TTS; -using Robust.Shared.Random; +using Content.Shared.Preferences; // ReSharper disable InconsistentNaming // ReSharper disable once CheckNamespace @@ -10,26 +9,15 @@ namespace Content.Client.Lobby.UI; public sealed partial class HumanoidProfileEditor { - private TTSSystem _ttsSystem = default!; - private TTSManager _ttsManager = default!; - private IRobustRandom _random = default!; - - private List _voiceList = default!; - - private readonly string[] _sampleText = - [ - "Помогите, клоун насилует в технических тоннелях!", - "ХоС, ваши сотрудники украли у меня собаку и засунули ее в стиральную машину!", - "Агент синдиката украл пиво из бара и взорвался!", - "Врача! Позовите врача!" - ]; + private List _voiceList = new(); private void InitializeVoice() { - _random = IoCManager.Resolve(); - _ttsManager = IoCManager.Resolve(); - _ttsSystem = IoCManager.Resolve().System(); - _voiceList = _prototypeManager.EnumeratePrototypes().Where(o => o.RoundStart).ToList(); + _voiceList = _prototypeManager + .EnumeratePrototypes() + .Where(o => o.RoundStart) + .OrderBy(o => Loc.GetString(o.Name)) + .ToList(); VoiceButton.OnItemSelected += args => { @@ -37,7 +25,7 @@ private void InitializeVoice() SetVoice(_voiceList[args.Id].ID); }; - VoicePlayButton.OnPressed += _ => { PlayTTS(); }; + VoicePlayButton.OnPressed += _ => PlayPreviewTTS(); } private void UpdateTTSVoicesControls() @@ -62,16 +50,18 @@ private void UpdateTTSVoicesControls() } var voiceChoiceId = _voiceList.FindIndex(x => x.ID == Profile.Voice); - if (!VoiceButton.TrySelectId(voiceChoiceId) && VoiceButton.TrySelectId(firstVoiceChoiceId)) + if (!VoiceButton.TrySelectId(voiceChoiceId) && + VoiceButton.TrySelectId(firstVoiceChoiceId)) + { SetVoice(_voiceList[firstVoiceChoiceId].ID); + } } - private void PlayTTS() + private void PlayPreviewTTS() { if (Profile is null) return; - _ttsSystem.StopCurrentTTS(PreviewDummy); - _ttsManager.RequestTTS(PreviewDummy, _random.Pick(_sampleText), Profile.Voice); + _entManager.System().RequestGlobalTTS(VoiceRequestType.Preview,Profile.Voice); } } diff --git a/Content.Client/_White/TTS/TTSManager.cs b/Content.Client/_White/TTS/TTSManager.cs deleted file mode 100644 index f37989d41e..0000000000 --- a/Content.Client/_White/TTS/TTSManager.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Content.Shared._White.TTS; -using Robust.Shared.Network; - -namespace Content.Client._White.TTS; - -// ReSharper disable once InconsistentNaming -public sealed class TTSManager -{ - [Dependency] private readonly IClientNetManager _netMgr = default!; - [Dependency] private readonly EntityManager _entityManager = default!; - - public void Initialize() - { - _netMgr.RegisterNetMessage(); - } - - // ReSharper disable once InconsistentNaming - public void RequestTTS(EntityUid uid, string text, string voiceId) - { - var netEntity = _entityManager.GetNetEntity(uid); - var msg = new MsgRequestTTS { Text = text, Uid = netEntity, VoiceId = voiceId }; - _netMgr.ClientSendMessage(msg); - } -} diff --git a/Content.Client/_White/TTS/TTSSystem.cs b/Content.Client/_White/TTS/TTSSystem.cs index d8c9fef280..dec300755d 100644 --- a/Content.Client/_White/TTS/TTSSystem.cs +++ b/Content.Client/_White/TTS/TTSSystem.cs @@ -1,57 +1,70 @@ -using System.IO; using Content.Shared._White; -using Content.Shared._White.TTS.Events; +using Content.Shared.Chat; +using Content.Shared._White.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.Utility; namespace Content.Client._White.TTS; -// ReSharper disable InconsistentNaming +/// +/// Plays TTS audio in world +/// +// ReSharper disable once InconsistentNaming public sealed class TTSSystem : EntitySystem { - [Dependency] private readonly IAudioManager _audioManager = default!; [Dependency] private readonly IConfigurationManager _cfg = default!; - [Dependency] private readonly AudioSystem _audioSystem = default!; + [Dependency] private readonly IResourceManager _res = default!; + [Dependency] private readonly AudioSystem _audio = default!; - private float _volume; - private readonly Dictionary _currentlyPlaying = new(); + private ISawmill _sawmill = default!; + private readonly MemoryContentRoot _contentRoot = new(); + private ResPath _prefix; - private readonly Dictionary> _enquedStreams = new(); + /// + /// Reducing the volume of the TTS when whispering. Will be converted to logarithm. + /// + private const float WhisperFade = 4f; - // Same as Server.ChatSystem.VoiceRange - private const float VoiceRange = 10; + /// + /// The volume at which the TTS sound will not be heard. + /// + private const float MinimalVolume = -10f; + + private float _volume = 0.0f; + private ulong _fileIdx = 0; + private static ulong _shareIdx = 0; public override void Initialize() { + _prefix = ResPath.Root / $"TTS{_shareIdx++}"; + _sawmill = Logger.GetSawmill("tts"); + _res.AddRoot(_prefix, _contentRoot); _cfg.OnValueChanged(WhiteCVars.TTSVolume, OnTtsVolumeChanged, true); - SubscribeNetworkEvent(OnPlayTTS); + SubscribeLocalEvent(OnRoundRestart); + } + + private void OnRoundRestart(RoundRestartCleanupEvent ev) + { + _contentRoot.Clear(); } public override void Shutdown() { base.Shutdown(); _cfg.UnsubValueChanged(WhiteCVars.TTSVolume, OnTtsVolumeChanged); - ClearQueues(); + _contentRoot.Dispose(); } - public override void FrameUpdate(float frameTime) + public void RequestGlobalTTS(VoiceRequestType text, string voiceId) { - foreach (var (uid, audioComponent) in _currentlyPlaying) - { - if (!Deleted(uid) && audioComponent is { Running: true, Playing: true } - || !_enquedStreams.TryGetValue(uid, out var queue) - || !queue.TryDequeue(out var toPlay)) - continue; - - var audio = _audioSystem.PlayEntity(toPlay.Stream, uid, toPlay.Params); - if (!audio.HasValue) - continue; - - _currentlyPlaying[uid] = audio.Value.Component; - } + RaiseNetworkEvent(new RequestPreviewTTSEvent(voiceId)); } private void OnTtsVolumeChanged(float volume) @@ -61,70 +74,46 @@ private void OnTtsVolumeChanged(float volume) private void OnPlayTTS(PlayTTSEvent ev) { - PlayTTS(GetEntity(ev.Uid), ev.Data, ev.BoostVolume ? _volume + 5 : _volume); - } - - public void PlayTTS(EntityUid uid, byte[] data, float volume) - { - if (_volume <= -20f) - return; + _sawmill.Verbose($"Play TTS audio {ev.Data.Length} bytes from {ev.SourceUid} entity"); - var stream = CreateAudioStream(data); + var filePath = new ResPath($"{_fileIdx++}.ogg"); + _contentRoot.AddOrUpdateFile(filePath, ev.Data); - var audioParams = new AudioParams - { - Volume = volume, - MaxDistance = VoiceRange - }; - - var audioStream = new AudioStreamWithParams(stream, audioParams); - EnqueueAudio(uid, audioStream); - } + var audioResource = new AudioResource(); + audioResource.Load(IoCManager.Instance!, _prefix / filePath); - public void StopCurrentTTS(EntityUid uid) - { - if (!_currentlyPlaying.TryGetValue(uid, out var audio)) - return; - - _audioSystem.Stop(audio.Owner); - } + var audioParams = AudioParams.Default + .WithVolume(AdjustVolume(ev.IsWhisper)) + .WithMaxDistance(AdjustDistance(ev.IsWhisper)); - private void EnqueueAudio(EntityUid uid, AudioStreamWithParams audioStream) - { - if (!_currentlyPlaying.ContainsKey(uid)) + if (ev.SourceUid != null) { - var audio = _audioSystem.PlayEntity(audioStream.Stream, uid, audioStream.Params); - if (!audio.HasValue) - return; - - _currentlyPlaying[uid] = audio.Value.Component; - return; + var sourceUid = GetEntity(ev.SourceUid.Value); + if(sourceUid.IsValid()) + _audio.PlayEntity(audioResource.AudioStream, sourceUid, audioParams); } - - if (_enquedStreams.TryGetValue(uid, out var queue)) + else { - queue.Enqueue(audioStream); - return; + _audio.PlayGlobal(audioResource.AudioStream, audioParams); } - queue = new Queue(); - queue.Enqueue(audioStream); - _enquedStreams[uid] = queue; + _contentRoot.RemoveFile(filePath); } - private void ClearQueues() + private float AdjustVolume(bool isWhisper) { - foreach (var (_, queue) in _enquedStreams) + var volume = Math.Max(MinimalVolume, SharedAudioSystem.GainToVolume(_volume)); + + if (isWhisper) { - queue.Clear(); + volume -= SharedAudioSystem.GainToVolume(WhisperFade); } + + return volume; } - private AudioStream CreateAudioStream(byte[] data) + private float AdjustDistance(bool isWhisper) { - var dataStream = new MemoryStream(data) { Position = 0 }; - return _audioManager.LoadAudioOggVorbis(dataStream); + return isWhisper ? SharedChatSystem.WhisperMuffledRange : SharedChatSystem.VoiceRange; } - - private record AudioStreamWithParams(AudioStream Stream, AudioParams Params); } diff --git a/Content.IntegrationTests/Tests/Preferences/ServerDbSqliteTests.cs b/Content.IntegrationTests/Tests/Preferences/ServerDbSqliteTests.cs index f2949fce7a..812e704822 100644 --- a/Content.IntegrationTests/Tests/Preferences/ServerDbSqliteTests.cs +++ b/Content.IntegrationTests/Tests/Preferences/ServerDbSqliteTests.cs @@ -45,7 +45,7 @@ private static HumanoidCharacterProfile CharlieCharlieson() Species = "Human", Customspeciename = "", Age = 21, - Voice = "Eugene", // WD EDIT + Voice = "Aidar", // WD EDIT Appearance = new( "Afro", Color.Aqua, diff --git a/Content.Server/VoiceMask/VoiceMaskComponent.cs b/Content.Server/VoiceMask/VoiceMaskComponent.cs index d3116f94db..73a1a6658d 100644 --- a/Content.Server/VoiceMask/VoiceMaskComponent.cs +++ b/Content.Server/VoiceMask/VoiceMaskComponent.cs @@ -1,3 +1,4 @@ +using Content.Shared.Humanoid; using Content.Shared.Speech; using Robust.Shared.Prototypes; @@ -32,6 +33,12 @@ public sealed partial class VoiceMaskComponent : Component [DataField] public EntProtoId Action = "ActionChangeVoiceMask"; + // WD EDIT START + [DataField] + [ViewVariables(VVAccess.ReadWrite)] + public string VoiceId = SharedHumanoidAppearanceSystem.DefaultVoice; + // WD EDIT END + /// /// Reference to the action. /// diff --git a/Content.Server/_White/Chat/Systems/AnnouncementSpokeEvent.cs b/Content.Server/_White/Chat/Systems/AnnouncementSpokeEvent.cs new file mode 100644 index 0000000000..445f9b1a9f --- /dev/null +++ b/Content.Server/_White/Chat/Systems/AnnouncementSpokeEvent.cs @@ -0,0 +1,33 @@ +using Robust.Shared.Audio; +using Robust.Shared.Player; + +namespace Content.Server._White.Chat.Systems; + +public sealed class AnnouncementSpokeEvent : EntityEventArgs +{ + public readonly Filter Source; + public readonly string AnnouncementSound; + public readonly AudioParams AnnouncementSoundParams; + public readonly string Message; + + public AnnouncementSpokeEvent(Filter source, string announcementSound, AudioParams announcementSoundParams, string message) + { + Source = source; + Message = message; + AnnouncementSound = announcementSound; + AnnouncementSoundParams = announcementSoundParams; + } +} + +public sealed class RadioSpokeEvent : EntityEventArgs +{ + public readonly EntityUid Source; + public readonly string Message; + + + public RadioSpokeEvent(EntityUid source, string message) + { + Source = source; + Message = message; + } +} diff --git a/Content.Server/_White/TTS/TTSManager.cs b/Content.Server/_White/TTS/TTSManager.cs index e108f236e1..de77fe890f 100644 --- a/Content.Server/_White/TTS/TTSManager.cs +++ b/Content.Server/_White/TTS/TTSManager.cs @@ -1,11 +1,12 @@ -using System.Linq; +using System.Linq; +using System.Net; using System.Net.Http; using System.Net.Http.Json; +using System.Runtime.CompilerServices; using System.Text; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using System.Web; using Content.Shared._White; using Prometheus; using Robust.Shared.Configuration; @@ -15,33 +16,47 @@ namespace Content.Server._White.TTS; // ReSharper disable once InconsistentNaming public sealed class TTSManager { - [Dependency] private readonly IConfigurationManager _cfg = default!; - private static readonly Histogram RequestTimings = Metrics.CreateHistogram( "tts_req_timings", "Timings of TTS API requests", - new HistogramConfiguration + new HistogramConfiguration() { - LabelNames = new[] { "type" }, + LabelNames = new[] {"type"}, Buckets = Histogram.ExponentialBuckets(.1, 1.5, 10), }); - private static readonly Counter WantedCount = - Metrics.CreateCounter("tts_wanted_count", "Amount of wanted TTS audio."); + private static readonly Counter WantedCount = Metrics.CreateCounter( + "tts_wanted_count", + "Amount of wanted TTS audio."); - private static readonly Counter ReusedCount = - Metrics.CreateCounter("tts_reused_count", "Amount of reused TTS audio from cache."); + private static readonly Counter ReusedCount = Metrics.CreateCounter( + "tts_reused_count", + "Amount of reused TTS audio from cache."); - private static readonly Gauge CachedCount = Metrics.CreateGauge("tts_cached_count", "Amount of cached TTS audio."); + [Robust.Shared.IoC.Dependency] private readonly IConfigurationManager _cfg = default!; private readonly HttpClient _httpClient = new(); - private readonly Dictionary _cache = new(); private ISawmill _sawmill = default!; + // ReSharper disable once InconsistentNaming + public readonly Dictionary _cache = new(); + // ReSharper disable once InconsistentNaming + public readonly HashSet _cacheKeysSeq = new(); + // ReSharper disable once InconsistentNaming + public int _maxCachedCount = 200; + private string _apiUrl = string.Empty; + private string _apiToken = string.Empty; public void Initialize() { _sawmill = Logger.GetSawmill("tts"); + _cfg.OnValueChanged(WhiteCVars.TTSMaxCache, val => + { + _maxCachedCount = val; + ResetCache(); + }, true); + _cfg.OnValueChanged(WhiteCVars.TTSApiUrl, v => _apiUrl = v, true); + _cfg.OnValueChanged(WhiteCVars.TTSApiToken, v => _apiToken = v, true); } /// @@ -49,59 +64,59 @@ public void Initialize() /// /// Identifier of speaker /// SSML formatted text - /// OGG audio bytes + /// OGG audio bytes or null if failed public async Task ConvertTextToSpeech(string speaker, string text) { - var url = _cfg.GetCVar(WhiteCVars.TTSApiUrl); - if (string.IsNullOrWhiteSpace(url)) - { - _sawmill.Log(LogLevel.Error, nameof(TTSManager), "TTS Api url not specified"); - return null; - } - - var token = _cfg.GetCVar(WhiteCVars.TTSApiToken); - if (string.IsNullOrWhiteSpace(token)) - { - _sawmill.Log(LogLevel.Error, nameof(TTSManager), "TTS Api token not specified"); - return null; - } - - var maxCacheSize = _cfg.GetCVar(WhiteCVars.TTSMaxCache); WantedCount.Inc(); var cacheKey = GenerateCacheKey(speaker, text); if (_cache.TryGetValue(cacheKey, out var data)) { ReusedCount.Inc(); - _sawmill.Debug($"Use cached sound for '{text}' speech by '{speaker}' speaker"); + _sawmill.Verbose($"Use cached sound for '{text}' speech by '{speaker}' speaker"); return data; } + _sawmill.Verbose($"Generate new audio for '{text}' speech by '{speaker}' speaker"); + var body = new GenerateVoiceRequest { - ApiToken = token, + ApiToken = _apiToken, Text = text, - Speaker = speaker + Speaker = speaker, }; var reqTime = DateTime.UtcNow; try { - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - var response = await _httpClient.PostAsJsonAsync(url, body, cts.Token); + var timeout = _cfg.GetCVar(WhiteCVars.TTSApiTimeout); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeout)); + var response = await _httpClient.PostAsJsonAsync(_apiUrl, body, cts.Token); if (!response.IsSuccessStatusCode) - throw new Exception($"TTS request returned bad status code: {response.StatusCode}"); - - var soundData = await response.Content.ReadAsByteArrayAsync(cts.Token); - - if (_cache.Count > maxCacheSize) - _cache.Remove(_cache.Last().Key); - - _cache.Add(cacheKey, soundData); - CachedCount.Inc(); - - _sawmill.Debug( - $"Generated new sound for '{text}' speech by '{speaker}' speaker ({soundData.Length} bytes)"); - + { + if (response.StatusCode == HttpStatusCode.TooManyRequests) + { + _sawmill.Warning("TTS request was rate limited"); + return null; + } + + _sawmill.Error($"TTS request returned bad status code: {response.StatusCode}"); + return null; + } + + var json = await response.Content.ReadFromJsonAsync(cancellationToken: cts.Token); + var soundData = Convert.FromBase64String(json.Results.First().Audio); + + + _cache.TryAdd(cacheKey, soundData); + _cacheKeysSeq.Add(cacheKey); + if (_cache.Count > _maxCachedCount) + { + var firstKey = _cacheKeysSeq.First(); + _cache.Remove(firstKey); + _cacheKeysSeq.Remove(firstKey); + } + + _sawmill.Debug($"Generated new audio for '{text}' speech by '{speaker}' speaker ({soundData.Length} bytes)"); RequestTimings.WithLabels("Success").Observe((DateTime.UtcNow - reqTime).TotalSeconds); return soundData; @@ -109,13 +124,13 @@ public void Initialize() catch (TaskCanceledException) { RequestTimings.WithLabels("Timeout").Observe((DateTime.UtcNow - reqTime).TotalSeconds); - _sawmill.Warning($"Timeout of request generation new sound for '{text}' speech by '{speaker}' speaker"); + _sawmill.Error($"Timeout of request generation new audio for '{text}' speech by '{speaker}' speaker"); return null; } catch (Exception e) { RequestTimings.WithLabels("Error").Observe((DateTime.UtcNow - reqTime).TotalSeconds); - _sawmill.Warning($"Failed of request generation new sound for '{text}' speech by '{speaker}' speaker\n{e}"); + _sawmill.Error($"Failed of request generation new sound for '{text}' speech by '{speaker}' speaker\n{e}"); return null; } } @@ -123,19 +138,23 @@ public void Initialize() public void ResetCache() { _cache.Clear(); - CachedCount.Set(0); + _cacheKeysSeq.Clear(); } + [MethodImpl(MethodImplOptions.AggressiveOptimization)] private string GenerateCacheKey(string speaker, string text) { - var key = $"{speaker}/{text}"; - var keyData = Encoding.UTF8.GetBytes(key); - var bytes = System.Security.Cryptography.SHA256.HashData(keyData); - return Convert.ToHexString(bytes); + var keyData = Encoding.UTF8.GetBytes($"{speaker}/{text}"); + var hashBytes = System.Security.Cryptography.SHA256.HashData(keyData); + return Convert.ToHexString(hashBytes); } - private record GenerateVoiceRequest + private struct GenerateVoiceRequest { + public GenerateVoiceRequest() + { + } + [JsonPropertyName("api_token")] public string ApiToken { get; set; } = ""; diff --git a/Content.Server/_White/TTS/TTSManagerExtension.cs b/Content.Server/_White/TTS/TTSManagerExtension.cs new file mode 100644 index 0000000000..4363d4385d --- /dev/null +++ b/Content.Server/_White/TTS/TTSManagerExtension.cs @@ -0,0 +1,253 @@ +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Content.Shared._White; +using Prometheus; +using Robust.Shared.Configuration; + +namespace Content.Server._White.TTS; + +// ReSharper disable once InconsistentNaming +public static class TTSManagerExtension +{ + private static readonly Histogram AnnounceRequestTimings = Metrics.CreateHistogram( + "tts_announce_req_timings", + "Timings announce of TTS API requests", + new HistogramConfiguration() + { + LabelNames = new[] {"type"}, + Buckets = Histogram.ExponentialBuckets(.1, 1.5, 10), + }); + + private static readonly Counter AnnounceWantedCount = Metrics.CreateCounter( + "tts_announce_wanted_count", + "Amount announce of wanted TTS audio."); + + private static readonly Counter AnnounceReusedCount = Metrics.CreateCounter( + "tts_announce_reused_count", + "Amount announce of reused TTS audio from cache."); + + private static readonly Histogram RadioRequestTimings = Metrics.CreateHistogram( + "tts_radio_req_timings", + "Timings radio of TTS API requests", + new HistogramConfiguration() + { + LabelNames = new[] {"type"}, + Buckets = Histogram.ExponentialBuckets(.1, 1.5, 10), + }); + + private static readonly Counter RadioWantedCount = Metrics.CreateCounter( + "tts_radio_wanted_count", + "Amount radio of wanted TTS audio."); + + private static readonly Counter RadioReusedCount = Metrics.CreateCounter( + "tts_radio_reused_count", + "Amount radio of reused TTS audio from cache."); + + private static readonly HttpClient _httpClient = new(); + + public static async Task RadioConvertTextToSpeech(this TTSManager _cfTtsManager, string speaker, string text) + { + // ReSharper disable once InconsistentNaming + var _sawmill = Logger.GetSawmill("tts"); + // ReSharper disable once InconsistentNaming + var _cfg = IoCManager.Resolve(); + + var url = _cfg.GetCVar(WhiteCVars.TTSApiUrl); + if (string.IsNullOrWhiteSpace(url)) + { + throw new Exception("TTS Api url not specified"); + } + + var token = _cfg.GetCVar(WhiteCVars.TTSApiToken); + if (string.IsNullOrWhiteSpace(token)) + { + throw new Exception("TTS Api token not specified"); + } + + RadioWantedCount.Inc(); + var cacheKey = GenerateCacheKey(speaker, text, "echo"); + if (_cfTtsManager._cache.TryGetValue(cacheKey, out var data)) + { + RadioReusedCount.Inc(); + _sawmill.Debug($"Use cached radio sound for '{text}' speech by '{speaker}' speaker"); + return data; + } + + var body = new GenerateVoiceRequest + { + ApiToken = token, + Text = text, + Speaker = speaker, + Effect = "Radio" + }; + + var reqTime = DateTime.UtcNow; + try + { + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(4)); + var response = await _httpClient.PostAsJsonAsync(url, body, cts.Token); + if (!response.IsSuccessStatusCode) + { + throw new Exception($"TTS request returned bad status code: {response.StatusCode}"); + } + + var json = await response.Content.ReadFromJsonAsync(); + var soundData = Convert.FromBase64String(json.Results.First().Audio); + + _cfTtsManager._cache.Add(cacheKey, soundData); + _cfTtsManager._cacheKeysSeq.Add(cacheKey); + + _sawmill.Debug($"Generated new radio sound for '{text}' speech by '{speaker}' speaker ({soundData.Length} bytes)"); + RadioRequestTimings.WithLabels("Success").Observe((DateTime.UtcNow - reqTime).TotalSeconds); + + return soundData; + } + catch (TaskCanceledException) + { + RadioRequestTimings.WithLabels("Timeout").Observe((DateTime.UtcNow - reqTime).TotalSeconds); + _sawmill.Error($"Timeout of request generation new radio sound for '{text}' speech by '{speaker}' speaker"); + throw new Exception("TTS request timeout"); + } + catch (Exception e) + { + RadioRequestTimings.WithLabels("Error").Observe((DateTime.UtcNow - reqTime).TotalSeconds); + _sawmill.Error($"Failed of request generation new radio sound for '{text}' speech by '{speaker}' speaker\n{e}"); + throw new Exception("TTS request failed"); + } + } + public static async Task AnnounceConvertTextToSpeech(this TTSManager _cfTtsManager, string speaker, string text) + { + // ReSharper disable once InconsistentNaming + var _sawmill = Logger.GetSawmill("tts"); + // ReSharper disable once InconsistentNaming + var _cfg = IoCManager.Resolve(); + + var url = _cfg.GetCVar(WhiteCVars.TTSApiUrl); + if (string.IsNullOrWhiteSpace(url)) + { + throw new Exception("TTS Api url not specified"); + } + + var token = _cfg.GetCVar(WhiteCVars.TTSApiToken); + if (string.IsNullOrWhiteSpace(token)) + { + throw new Exception("TTS Api token not specified"); + } + + AnnounceWantedCount.Inc(); + var cacheKey = GenerateCacheKey(speaker, text, "echo"); + if (_cfTtsManager._cache.TryGetValue(cacheKey, out var data)) + { + AnnounceReusedCount.Inc(); + _sawmill.Debug($"Use cached announce sound for '{text}' speech by '{speaker}' speaker"); + return data; + } + + var body = new GenerateVoiceRequest + { + ApiToken = token, + Text = text, + Speaker = speaker, + Effect = "Echo" + }; + + var reqTime = DateTime.UtcNow; + try + { + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var response = await _httpClient.PostAsJsonAsync(url, body, cts.Token); + if (!response.IsSuccessStatusCode) + { + throw new Exception($"TTS request returned bad status code: {response.StatusCode}"); + } + + var json = await response.Content.ReadFromJsonAsync(); + var soundData = Convert.FromBase64String(json.Results.First().Audio); + + _cfTtsManager._cache.Add(cacheKey, soundData); + _cfTtsManager._cacheKeysSeq.Add(cacheKey); + + _sawmill.Debug($"Generated new announce sound for '{text}' speech by '{speaker}' speaker ({soundData.Length} bytes)"); + AnnounceRequestTimings.WithLabels("Success").Observe((DateTime.UtcNow - reqTime).TotalSeconds); + + return soundData; + } + catch (TaskCanceledException) + { + AnnounceRequestTimings.WithLabels("Timeout").Observe((DateTime.UtcNow - reqTime).TotalSeconds); + _sawmill.Error($"Timeout of request generation new announce sound for '{text}' speech by '{speaker}' speaker"); + throw new Exception("TTS request timeout"); + } + catch (Exception e) + { + AnnounceRequestTimings.WithLabels("Error").Observe((DateTime.UtcNow - reqTime).TotalSeconds); + _sawmill.Error($"Failed of request generation new announce sound for '{text}' speech by '{speaker}' speaker\n{e}"); + throw new Exception("TTS request failed", e); + } + } + + private static string GenerateCacheKey(string speaker, string text, string effect = "") + { + var key = $"{speaker}/{text}/{effect}"; + byte[] keyData = Encoding.UTF8.GetBytes(key); + var bytes = System.Security.Cryptography.SHA1.HashData(keyData); + return Convert.ToHexString(bytes); + } + + private struct GenerateVoiceRequest + { + public GenerateVoiceRequest() + { + } + + [JsonPropertyName("api_token")] + public string ApiToken { get; set; } = ""; + + [JsonPropertyName("text")] + public string Text { get; set; } = ""; + + [JsonPropertyName("speaker")] + public string Speaker { get; set; } = ""; + + [JsonPropertyName("ssml")] + public bool SSML { get; private set; } = true; + + [JsonPropertyName("word_ts")] + public bool WordTS { get; private set; } = false; + + [JsonPropertyName("put_accent")] + public bool PutAccent { get; private set; } = true; + + [JsonPropertyName("put_yo")] + public bool PutYo { get; private set; } = false; + + [JsonPropertyName("sample_rate")] + public int SampleRate { get; private set; } = 24000; + + [JsonPropertyName("format")] + public string Format { get; private set; } = "ogg"; + + [JsonPropertyName("effect")] + public string Effect { get; set; } = "none"; + } + + private struct GenerateVoiceResponse + { + [JsonPropertyName("results")] + public List Results { get; set; } + + [JsonPropertyName("original_sha1")] + public string Hash { get; set; } + } + + private struct VoiceResult + { + [JsonPropertyName("audio")] + public string Audio { get; set; } + } +} diff --git a/Content.Server/_White/TTS/TTSSystem.Announce.cs b/Content.Server/_White/TTS/TTSSystem.Announce.cs new file mode 100644 index 0000000000..da1f657e64 --- /dev/null +++ b/Content.Server/_White/TTS/TTSSystem.Announce.cs @@ -0,0 +1,52 @@ +using System.Threading.Tasks; +using Content.Server._White.Chat.Systems; +using Content.Shared._White.TTS; + +namespace Content.Server._White.TTS; + +// ReSharper disable once InconsistentNaming +public sealed partial class TTSSystem +{ + private string _voiceId = "Announcer"; + + private async void OnAnnouncementSpoke(AnnouncementSpokeEvent args) + { + if (!_isEnabled || + args.Message.Length > MaxMessageChars * 2 || + !_prototypeManager.TryIndex(_voiceId, out var protoVoice)) + { + RaiseNetworkEvent(new AnnounceTTSEvent(new byte[]{}, args.AnnouncementSound, args.AnnouncementSoundParams), args.Source); + return; + } + + byte[]? soundData = null; + try + { + soundData = await GenerateTtsAnnouncement(args.Message, protoVoice.Speaker); + + } + catch (Exception) + { + // skip! + } + soundData ??= new byte[] { }; + RaiseNetworkEvent(new AnnounceTTSEvent(soundData, args.AnnouncementSound, args.AnnouncementSoundParams), args.Source); + } + + private async Task GenerateTtsAnnouncement(string text, string speaker) + { + var textSanitized = Sanitize(text); + if (textSanitized == "") return null; + if (char.IsLetter(textSanitized[^1])) + textSanitized += "."; + + var textSsml = ToSsmlText(textSanitized, SoundTraits.RateFast); + + var position = textSsml.LastIndexOf("Отправитель", StringComparison.InvariantCulture); + if (position != -1) + { + textSsml = textSsml[..position] + "" + textSsml[position..]; + } + return await _ttsManager.AnnounceConvertTextToSpeech(speaker, textSsml); + } +} diff --git a/Content.Server/_White/TTS/TTSSystem.Cache.cs b/Content.Server/_White/TTS/TTSSystem.Cache.cs new file mode 100644 index 0000000000..1c4450cda4 --- /dev/null +++ b/Content.Server/_White/TTS/TTSSystem.Cache.cs @@ -0,0 +1,37 @@ +using System.Threading.Tasks; +using Content.Shared._White.TTS; +using Robust.Shared.ContentPack; +using Robust.Shared.Utility; + +namespace Content.Server._White.TTS; + +// ReSharper disable once InconsistentNaming +public sealed partial class TTSSystem +{ + [Dependency] private readonly IResourceManager _resourceManager = default!; + + private ResPath GetCacheId(TTSVoicePrototype voicePrototype, string cacheId) + { + var resPath = new ResPath($"voicecache/{voicePrototype.ID}/{cacheId}.ogg").ToRootedPath(); + _resourceManager.UserData.CreateDir(resPath.Directory); + return resPath.ToRootedPath(); + } + private async Task GetFromCache(ResPath resPath) + { + if (!_resourceManager.UserData.Exists(resPath)) + { + return null; + } + + await using var reader = _resourceManager.UserData.OpenRead(resPath); + return reader.CopyToArray(); + } + + private async Task SaveVoiceCache(ResPath resPath, byte[] data) + { + await using var writer = _resourceManager.UserData.OpenWrite(resPath); + await writer.WriteAsync(data); + } + + +} diff --git a/Content.Server/_White/TTS/TTSSystem.Preview.cs b/Content.Server/_White/TTS/TTSSystem.Preview.cs new file mode 100644 index 0000000000..c705d693a0 --- /dev/null +++ b/Content.Server/_White/TTS/TTSSystem.Preview.cs @@ -0,0 +1,56 @@ +using Content.Shared._White.TTS; +using Robust.Shared.Player; +using Robust.Shared.Random; + +namespace Content.Server._White.TTS; + +// ReSharper disable once InconsistentNaming +public sealed partial class TTSSystem +{ + [Dependency] private readonly IRobustRandom _robustRandom = default!; + + private readonly List _sampleText = new() // TODO: Локализация? + { + "Съешь же ещё этих мягких французских булок, да выпей чаю.", + "Клоун, прекрати разбрасывать банановые кожурки офицерам под ноги!", + "Капитан, вы уверены что хотите назначить клоуна на должность главы персонала?", + "Эс Бэ! Тут человек в сером костюме, с тулбоксом и в маске! Помогите!!", + "Учёные, тут странная аномалия в баре! Она уже съела мима!", + "Я надеюсь что инженеры внимательно следят за сингулярностью...", + "Вы слышали эти странные крики в техах? Мне кажется туда ходить небезопасно.", + "Вы не видели Гамлета? Мне кажется он забегал к вам на кухню.", + "Здесь есть доктор? Человек умирает от отравленного пончика! Нужна помощь!", + "Вам нужно согласие и печать квартирмейстера, если вы хотите сделать заказ на партию дробовиков.", + "Возле эвакуационного шаттла разгерметизация! Инженеры, нам срочно нужна ваша помощь!", + "Бармен, налей мне самого крепкого вина, которое есть в твоих запасах!" + }; + + /// + /// Вообще не понимаю на какой хрен позволять пользователяем ддосить сервер ттса да и еще своим любым текстом -_- + /// + /// + private async void OnRequestPreviewTTS(RequestPreviewTTSEvent ev, EntitySessionEventArgs args) + { + if (!_isEnabled || + !_prototypeManager.TryIndex(ev.VoiceId, out var protoVoice)) + return; + + var txt = _robustRandom.Pick(_sampleText); + var cacheId = GetCacheId(protoVoice, $"{VoiceRequestType.Preview.ToString()}-{_sampleText.IndexOf(txt)}"); + + var cached = await GetFromCache(cacheId); + if (cached != null) + { + RaiseNetworkEvent(new PlayTTSEvent(cached), Filter.SinglePlayer(args.SenderSession)); + return; + } + + var soundData = await GenerateTTS(txt, protoVoice.Speaker); + if (soundData is null) + return; + + RaiseNetworkEvent(new PlayTTSEvent(soundData), Filter.SinglePlayer(args.SenderSession), false); // not record for replay + + await SaveVoiceCache(cacheId, soundData); + } +} diff --git a/Content.Server/_White/TTS/TTSSystem.SSML.cs b/Content.Server/_White/TTS/TTSSystem.SSML.cs new file mode 100644 index 0000000000..70acea51a6 --- /dev/null +++ b/Content.Server/_White/TTS/TTSSystem.SSML.cs @@ -0,0 +1,23 @@ +namespace Content.Server._White.TTS; + +// ReSharper disable once InconsistentNaming +public sealed partial class TTSSystem +{ + private string ToSsmlText(string text, SoundTraits traits = SoundTraits.None) + { + var result = text; + if (traits.HasFlag(SoundTraits.RateFast)) + result = $"{result}"; + if (traits.HasFlag(SoundTraits.PitchVerylow)) + result = $"{result}"; + return $"{result}"; + } + + [Flags] + private enum SoundTraits : ushort + { + None = 0, + RateFast = 1 << 0, + PitchVerylow = 1 << 1, + } +} diff --git a/Content.Server/_White/TTS/TTSSystem.Sanitize.cs b/Content.Server/_White/TTS/TTSSystem.Sanitize.cs index c799017529..e582d1da54 100644 --- a/Content.Server/_White/TTS/TTSSystem.Sanitize.cs +++ b/Content.Server/_White/TTS/TTSSystem.Sanitize.cs @@ -1,4 +1,4 @@ -using System.Text; +using System.Text; using System.Text.RegularExpressions; using Content.Server.Chat.Systems; @@ -7,243 +7,305 @@ namespace Content.Server._White.TTS; // ReSharper disable once InconsistentNaming public sealed partial class TTSSystem { - [GeneratedRegex("Ё")] - private static partial Regex RegexE(); - - [GeneratedRegex(@"[^a-zA-Zа-яА-ЯёЁ0-9,\-,+, ,?,!,.]")] - private static partial Regex SpecialCharactersRegex(); - - [GeneratedRegex("(?(OnTransformSpeech); - } - private void OnTransformSpeech(TransformSpeechEvent args) { - if (!_isEnabled) - return; + if (!_isEnabled) return; args.Message = args.Message.Replace("+", ""); } - private static string Sanitize(string text) + private string Sanitize(string text) { text = text.Trim(); - text = RegexE().Replace(text, "Е"); - text = SpecialCharactersRegex().Replace(text, ""); - text = MatchedWordsRegex().Replace(text, ReplaceMatchedWord); - text = FractionalNumbersRegex().Replace(text, " целых "); - text = WordsToNumbersRegex().Replace(text, ReplaceWord2Num); + text = Regex.Replace(text, @"[^a-zA-Zа-яА-ЯёЁ0-9,\-+?!. ]", ""); + text = Regex.Replace(text, @"[a-zA-Z]", ReplaceLat2Cyr, RegexOptions.Multiline | RegexOptions.IgnoreCase); + text = Regex.Replace(text, @"(? WordReplacement = new() + private string ReplaceWord2Num(Match word) { - { "нт", "Эн Тэ" }, - { "смо", "Эс Мэ О" }, - { "гп", "Гэ Пэ" }, - { "рд", "Эр Дэ" }, - { "гсб", "Гэ Эс Бэ" }, - { "гв", "Гэ Вэ" }, - { "нр", "Эн Эр" }, - { "срп", "Эс Эр Пэ" }, - { "цк", "Цэ Каа" }, - { "рнд", "Эр Эн Дэ" }, - { "сб", "Эс Бэ" }, - { "рцд", "Эр Цэ Дэ" }, - { "брпд", "Бэ Эр Пэ Дэ" }, - { "рпд", "Эр Пэ Дэ" }, - { "рпед", "Эр Пед" }, - { "тсф", "Тэ Эс Эф" }, - { "срт", "Эс Эр Тэ" }, - { "обр", "О Бэ Эр" }, - { "кпк", "Кэ Пэ Каа" }, - { "пда", "Пэ Дэ А" }, - { "id", "Ай Ди" }, - { "мщ", "Эм Ще" }, - { "вт", "Вэ Тэ" }, - { "ерп", "Йе Эр Пэ" }, - { "се", "Эс Йе" }, - { "апц", "А Пэ Цэ" }, - { "лкп", "Эл Ка Пэ" }, - { "см", "Эс Эм" }, - { "ека", "Йе Ка" }, - { "ка", "Кэ А" }, - { "бса", "Бэ Эс Аа" }, - { "тк", "Тэ Ка" }, - { "бфл", "Бэ Эф Эл" }, - { "бщ", "Бэ Щэ" }, - { "кк", "Кэ Ка" }, - { "ск", "Эс Ка" }, - { "зк", "Зэ Ка" }, - { "ерт", "Йе Эр Тэ" }, - { "вкд", "Вэ Ка Дэ" }, - { "нтр", "Эн Тэ Эр" }, - { "пнт", "Пэ Эн Тэ" }, - { "авд", "А Вэ Дэ" }, - { "пнв", "Пэ Эн Вэ" }, - { "ссд", "Эс Эс Дэ" }, - { "кпб", "Кэ Пэ Бэ" }, - { "сссп", "Эс Эс Эс Пэ" }, - { "крб", "Ка Эр Бэ" }, - { "бд", "Бэ Дэ" }, - { "сст", "Эс Эс Тэ" }, - { "скс", "Эс Ка Эс" }, - { "икн", "И Ка Эн" }, - { "нсс", "Эн Эс Эс" }, - { "емп", "Йе Эм Пэ" }, - { "бс", "Бэ Эс" }, - { "цкс", "Цэ Ка Эс" }, - { "срд", "Эс Эр Дэ" }, - { "жпс", "Джи Пи Эс" }, - { "gps", "Джи Пи Эс" }, - { "ннксс", "Эн Эн Ка Эс Эс" }, - { "ss", "Эс Эс" }, - { "сс", "Эс Эс" }, - { "тесла", "тэсла" }, - { "трейзен", "трэйзэн" }, - { "нанотрейзен", "нанотрэйзэн" }, - { "рпзд", "Эр Пэ Зэ Дэ" }, - { "кз", "Кэ Зэ" }, - }; + if (!long.TryParse(word.Value, out var number)) + return word.Value; + return NumberConverter.NumberToText(number); + } - #endregion + private static readonly IReadOnlyDictionary WordReplacement = + new Dictionary() + { + {"нт", "Эн Тэ"}, + {"смо", "Эс Мэ О"}, + {"гп", "Гэ Пэ"}, + {"рд", "Эр Дэ"}, + {"гсб", "Гэ Эс Бэ"}, + {"гв", "Гэ Вэ"}, + {"нр", "Эн Эр"}, + {"нра", "Эн Эра"}, + {"нру", "Эн Эру"}, + {"км", "Кэ Эм"}, + {"кма", "Кэ Эма"}, + {"кму", "Кэ Эму"}, + {"си", "Эс И"}, + {"срп", "Эс Эр Пэ"}, + {"цк", "Цэ Каа"}, + {"сцк", "Эс Цэ Каа"}, + {"пцк", "Пэ Цэ Каа"}, + {"оцк", "О Цэ Каа"}, + {"шцк", "Эш Цэ Каа"}, + {"ншцк", "Эн Эш Цэ Каа"}, + {"дсо", "Дэ Эс О"}, + {"рнд", "Эр Эн Дэ"}, + {"сб", "Эс Бэ"}, + {"рцд", "Эр Цэ Дэ"}, + {"брпд", "Бэ Эр Пэ Дэ"}, + {"рпд", "Эр Пэ Дэ"}, + {"рпед", "Эр Пед"}, + {"тсф", "Тэ Эс Эф"}, + {"срт", "Эс Эр Тэ"}, + {"обр", "О Бэ Эр"}, + {"кпк", "Кэ Пэ Каа"}, + {"пда", "Пэ Дэ А"}, + {"id", "Ай Ди"}, + {"мщ", "Эм Ще"}, + {"вт", "Вэ Тэ"}, + {"wt", "Вэ Тэ"}, + {"ерп", "Йе Эр Пэ"}, + {"се", "Эс Йе"}, + {"апц", "А Пэ Цэ"}, + {"лкп", "Эл Ка Пэ"}, + {"см", "Эс Эм"}, + {"ека", "Йе Ка"}, + {"ка", "Кэ А"}, + {"бса", "Бэ Эс Аа"}, + {"тк", "Тэ Ка"}, + {"бфл", "Бэ Эф Эл"}, + {"бщ", "Бэ Щэ"}, + {"кк", "Кэ Ка"}, + {"ск", "Эс Ка"}, + {"зк", "Зэ Ка"}, + {"ерт", "Йе Эр Тэ"}, + {"вкд", "Вэ Ка Дэ"}, + {"нтр", "Эн Тэ Эр"}, + {"пнт", "Пэ Эн Тэ"}, + {"авд", "А Вэ Дэ"}, + {"пнв", "Пэ Эн Вэ"}, + {"ссд", "Эс Эс Дэ"}, + {"крс", "Ка Эр Эс"}, + {"кпб", "Кэ Пэ Бэ"}, + {"сссп", "Эс Эс Эс Пэ"}, + {"крб", "Ка Эр Бэ"}, + {"бд", "Бэ Дэ"}, + {"сст", "Эс Эс Тэ"}, + {"скс", "Эс Ка Эс"}, + {"икн", "И Ка Эн"}, + {"нсс", "Эн Эс Эс"}, + {"емп", "Йе Эм Пэ"}, + {"бс", "Бэ Эс"}, + {"цкс", "Цэ Ка Эс"}, + {"срд", "Эс Эр Дэ"}, + {"жпс", "Джи Пи Эс"}, + {"gps", "Джи Пи Эс"}, + {"ннксс", "Эн Эн Ка Эс Эс"}, + {"ss", "Эс Эс"}, + {"тесла", "тэсла"}, + {"трейзен", "трэйзэн"}, + {"нанотрейзен", "нанотрэйзэн"}, + {"рпзд", "Эр Пэ Зэ Дэ"}, + {"кз", "Кэ Зэ"}, + {"рхбз", "Эр Хэ Бэ Зэ"}, + {"рхбзз", "Эр Хэ Бэ Зэ Зэ"}, + {"днк", "Дэ Эн Ка"}, + {"мк", "Эм Ка"}, + {"mk", "Эм Ка"}, + {"рпг", "Эр Пэ Гэ"}, + {"с4", "Си 4"}, // cyrillic + {"c4", "Си 4"}, // latinic + {"бсс", "Бэ Эс Эс"}, + {"сии", "Эс И И"}, + {"ии", "И И"}, + {"опз", "О Пэ Зэ"}, + }; + + private static readonly IReadOnlyDictionary ReverseTranslit = + new Dictionary() + { + {"a", "а"}, + {"b", "б"}, + {"v", "в"}, + {"g", "г"}, + {"d", "д"}, + {"e", "е"}, + {"je", "ё"}, + {"zh", "ж"}, + {"z", "з"}, + {"i", "и"}, + {"y", "й"}, + {"k", "к"}, + {"l", "л"}, + {"m", "м"}, + {"n", "н"}, + {"o", "о"}, + {"p", "п"}, + {"r", "р"}, + {"s", "с"}, + {"t", "т"}, + {"u", "у"}, + {"f", "ф"}, + {"h", "х"}, + {"c", "ц"}, + {"x", "кс"}, + {"ch", "ч"}, + {"sh", "ш"}, + {"jsh", "щ"}, + {"hh", "ъ"}, + {"ih", "ы"}, + {"jh", "ь"}, + {"eh", "э"}, + {"ju", "ю"}, + {"ja", "я"}, + }; } // Source: https://codelab.ru/s/csharp/digits2phrase public static class NumberConverter { private static readonly string[] Frac20Male = - [ + { "", "один", "два", "три", "четыре", "пять", "шесть", "семь", "восемь", "девять", "десять", "одиннадцать", "двенадцать", "тринадцать", "четырнадцать", "пятнадцать", "шестнадцать", "семнадцать", "восемнадцать", "девятнадцать" - ]; + }; private static readonly string[] Frac20Female = - [ + { "", "одна", "две", "три", "четыре", "пять", "шесть", "семь", "восемь", "девять", "десять", "одиннадцать", "двенадцать", "тринадцать", "четырнадцать", "пятнадцать", "шестнадцать", "семнадцать", "восемнадцать", "девятнадцать" - ]; + }; - private static readonly string[] Hunds = - [ - "", "сто", "двести", "триста", "четыреста", - "пятьсот", "шестьсот", "семьсот", "восемьсот", "девятьсот" - ]; + private static readonly string[] Hunds = + { + "", "сто", "двести", "триста", "четыреста", + "пятьсот", "шестьсот", "семьсот", "восемьсот", "девятьсот" + }; - private static readonly string[] Tens = - [ - "", "десять", "двадцать", "тридцать", "сорок", "пятьдесят", - "шестьдесят", "семьдесят", "восемьдесят", "девяносто" - ]; + private static readonly string[] Tens = + { + "", "десять", "двадцать", "тридцать", "сорок", "пятьдесят", + "шестьдесят", "семьдесят", "восемьдесят", "девяносто" + }; - public static string NumberToText(long value, bool male = true) + public static string NumberToText(long value, bool male = true) { - if (value >= (long) Math.Pow(10, 15)) - return string.Empty; + if (value >= (long)Math.Pow(10, 15)) + return String.Empty; if (value == 0) return "ноль"; - var str = new StringBuilder(); + var str = new StringBuilder(); - if (value < 0) - { - str.Append("минус"); - value = -value; - } + if (value < 0) + { + str.Append("минус"); + value = -value; + } value = AppendPeriod(value, 1000000000000, str, "триллион", "триллиона", "триллионов", true); value = AppendPeriod(value, 1000000000, str, "миллиард", "миллиарда", "миллиардов", true); value = AppendPeriod(value, 1000000, str, "миллион", "миллиона", "миллионов", true); value = AppendPeriod(value, 1000, str, "тысяча", "тысячи", "тысяч", false); - var hundreds = (int) (value / 100); - if (hundreds != 0) - AppendWithSpace(str, Hunds[hundreds]); + var hundreds = (int)(value / 100); + if (hundreds != 0) + AppendWithSpace(str, Hunds[hundreds]); - var less100 = (int) (value % 100); + var less100 = (int)(value % 100); var frac20 = male ? Frac20Male : Frac20Female; - if (less100 < 20) - AppendWithSpace(str, frac20[less100]); - else - { - var tens = less100 / 10; - AppendWithSpace(str, Tens[tens]); - var less10 = less100 % 10; - if (less10 != 0) - str.Append(" " + frac20[less100 % 10]); - } - - return str.ToString(); - } - - private static void AppendWithSpace(StringBuilder stringBuilder, string str) - { - if (stringBuilder.Length > 0) - stringBuilder.Append(' '); - stringBuilder.Append(str); - } - - private static long AppendPeriod(long value, long power, StringBuilder str, string declension1, string declension2, string declension5, bool male) - { - var thousands = (int) (value / power); - if (thousands <= 0) - return value; - - AppendWithSpace(str, NumberToText(thousands, male, declension1, declension2, declension5)); - return value % power; - } - - private static string NumberToText(long value, bool male, string valueDeclensionFor1, string valueDeclensionFor2, string valueDeclensionFor5) - { - return NumberToText(value, male) + " " + GetDeclension((int) (value % 10), - valueDeclensionFor1, - valueDeclensionFor2, - valueDeclensionFor5); - } - - private static string GetDeclension(int val, string one, string two, string five) - { - var t = val % 100 > 20 - ? val % 10 - : val % 20; - - return t switch - { - 1 => one, - 2 or 3 or 4 => two, - _ => five - }; - } + if (less100 < 20) + AppendWithSpace(str, frac20[less100]); + else + { + var tens = less100 / 10; + AppendWithSpace(str, Tens[tens]); + var less10 = less100 % 10; + if (less10 != 0) + str.Append(" " + frac20[less100%10]); + } + + return str.ToString(); + } + + private static void AppendWithSpace(StringBuilder stringBuilder, string str) + { + if (stringBuilder.Length > 0) + stringBuilder.Append(" "); + stringBuilder.Append(str); + } + + private static long AppendPeriod( + long value, + long power, + StringBuilder str, + string declension1, + string declension2, + string declension5, + bool male) + { + var thousands = (int)(value / power); + if (thousands > 0) + { + AppendWithSpace(str, NumberToText(thousands, male, declension1, declension2, declension5)); + return value % power; + } + return value; + } + + private static string NumberToText( + long value, + bool male, + string valueDeclensionFor1, + string valueDeclensionFor2, + string valueDeclensionFor5) + { + return + NumberToText(value, male) + + " " + + GetDeclension((int)(value % 10), valueDeclensionFor1, valueDeclensionFor2, valueDeclensionFor5); + } + + private static string GetDeclension(int val, string one, string two, string five) + { + var t = (val % 100 > 20) ? val % 10 : val % 20; + + switch (t) + { + case 1: + return one; + case 2: + case 3: + case 4: + return two; + default: + return five; + } + } } diff --git a/Content.Server/_White/TTS/TTSSystem.cs b/Content.Server/_White/TTS/TTSSystem.cs index 3ba5930e8c..89c2474fb2 100644 --- a/Content.Server/_White/TTS/TTSSystem.cs +++ b/Content.Server/_White/TTS/TTSSystem.cs @@ -1,21 +1,14 @@ -using System.Linq; -using System.Numerics; +using System.Threading; using System.Threading.Tasks; using Content.Server.Chat.Systems; -using Content.Server.Light.Components; -using Content.Server.Station.Components; -using Content.Server.Station.Systems; +using Content.Server._White.Chat.Systems; using Content.Shared._White; using Content.Shared._White.TTS; -using Content.Shared._White.TTS.Events; using Content.Shared.GameTicking; -using Robust.Server.Player; +using Content.Shared.Radio; using Robust.Shared.Configuration; -using Robust.Shared.Network; using Robust.Shared.Player; using Robust.Shared.Prototypes; -using Robust.Shared.Random; -using Robust.Shared.Utility; namespace Content.Server._White.TTS; @@ -26,185 +19,245 @@ public sealed partial class TTSSystem : EntitySystem [Dependency] private readonly IPrototypeManager _prototypeManager = default!; [Dependency] private readonly TTSManager _ttsManager = default!; [Dependency] private readonly SharedTransformSystem _xforms = default!; - [Dependency] private readonly IRobustRandom _random = default!; - [Dependency] private readonly IServerNetManager _netMgr = default!; - [Dependency] private readonly IPlayerManager _playerManager = default!; - [Dependency] private readonly TTSPitchRateSystem _ttsPitchRateSystem = default!; - [Dependency] private readonly StationSystem _stationSystem = default!; - [Dependency] private readonly EntityLookupSystem _lookup = default!; private const int MaxMessageChars = 100 * 2; // same as SingleBubbleCharLimit * 2 - private bool _isEnabled; - private string _apiUrl = string.Empty; - - private readonly string[] _whisperWords = ["тсс", "псс", "ччч", "ссч", "сфч", "тст"]; + private bool _isEnabled = false; public override void Initialize() { _cfg.OnValueChanged(WhiteCVars.TTSEnabled, v => _isEnabled = v, true); - _cfg.OnValueChanged(WhiteCVars.TTSApiUrl, url => _apiUrl = url, true); + _cfg.OnValueChanged(WhiteCVars.TTSAnnounceVoiceId, v => _voiceId = v, true); - SubscribeLocalEvent(OnEntitySpoke); + SubscribeLocalEvent(OnTransformSpeech); + SubscribeLocalEvent(OnRoundRestartCleanup); - SubscribeLocalEvent(OnAnnounceRequest); + SubscribeLocalEvent(OnAnnouncementSpoke); + SubscribeNetworkEvent(OnRequestPreviewTTS); + SubscribeLocalEvent(OnTtsInitialized); + SubscribeLocalEvent(OnEntitySpoke); + } + + private void OnTtsInitialized(Entity ent, ref MapInitEvent args) + { + if (ent.Comp.VoicePrototypeId == null && _prototypeManager.TryGetRandom(_robustRandom, out var newTtsVoice)) + { + ent.Comp.VoicePrototypeId = newTtsVoice.ID; + } + } - SubscribeLocalEvent(OnRoundRestartCleanup); - _netMgr.RegisterNetMessage(OnRequestTTS); + private void OnRoundRestartCleanup(RoundRestartCleanupEvent ev) + { + _ttsManager.ResetCache(); } - private async void OnEntitySpoke(EntityUid uid, TTSComponent component, EntitySpokeEvent args) + private async void OnEntitySpoke(EntityUid uid, TTSComponent component, EntitySpokeLanguageEvent args) { - if (!_isEnabled || string.IsNullOrEmpty(_apiUrl) || args.Message.Length > MaxMessageChars) + var voiceId = component.VoicePrototypeId; + if (!_isEnabled || + args.Message.Length > MaxMessageChars || + voiceId == null) return; - var voiceId = component.Prototype; var voiceEv = new TransformSpeakerVoiceEvent(uid, voiceId); RaiseLocalEvent(uid, voiceEv); voiceId = voiceEv.VoiceId; - if (!_prototypeManager.TryIndex(voiceId, out var protoVoice)) + if (voiceId == null || !_prototypeManager.TryIndex(voiceId.Value, out var protoVoice)) return; - var message = FormattedMessage.RemoveMarkup(args.Message); - - var soundData = await GenerateTTS(uid, message, protoVoice.Speaker); - if (soundData is null) - return; + if (args.IsWhisper) + { + if (args.OrgMsg.Count > 0 || args.ObsMsg.Count > 0) + { + if(args.OrgMsg.Count > 0) + HandleWhisper(uid, args.Message, args.ObfuscatedMessage!, protoVoice.Speaker, args.OrgMsg); + if(args.ObsMsg.Count > 0 && args is { LangMessage: not null, ObfuscatedLangMessage: not null }) + HandleWhisper(uid, args.LangMessage, args.ObfuscatedLangMessage, protoVoice.Speaker, args.ObsMsg); - var ttsEvent = new PlayTTSEvent(GetNetEntity(uid), soundData, false); + return; + } + HandleWhisper(uid, args.Message, args.ObfuscatedMessage, protoVoice.Speaker, null); - // Say - if (!args.IsWhisper) - { - RaiseNetworkEvent(ttsEvent, Filter.Pvs(uid), false); return; } - // Whisper - var chosenWhisperText = _random.Pick(_whisperWords); - var obfSoundData = await GenerateTTS(uid, chosenWhisperText, protoVoice.Speaker); - if (obfSoundData is null) - return; - var obfTtsEvent = new PlayTTSEvent(GetNetEntity(uid), obfSoundData, false); - var xformQuery = GetEntityQuery(); - var sourcePos = _xforms.GetWorldPosition(xformQuery.GetComponent(uid), xformQuery); - var receptions = Filter.Pvs(uid).Recipients; - - foreach (var session in receptions) + if (args.OrgMsg.Count > 0 || args.ObsMsg.Count > 0) { - if (!session.AttachedEntity.HasValue) - continue; - var xform = xformQuery.GetComponent(session.AttachedEntity.Value); - var distance = (sourcePos - _xforms.GetWorldPosition(xform, xformQuery)).LengthSquared(); - if (distance > ChatSystem.VoiceRange * ChatSystem.VoiceRange) - continue; - - EntityEventArgs actualEvent = distance > ChatSystem.WhisperClearRange - ? obfTtsEvent - : ttsEvent; - - RaiseNetworkEvent(actualEvent, Filter.SinglePlayer(session), false); + if(args.OrgMsg.Count > 0) + HandleSay(uid, args.Message, protoVoice.Speaker, args.OrgMsg); + if(args.ObsMsg.Count > 0) + HandleSay(uid, args.ObfuscatedMessage, protoVoice.Speaker, args.ObsMsg); + return; } + HandleSay(uid, args.Message, protoVoice.Speaker, null); } - private async void OnAnnounceRequest(TtsAnnouncementEvent ev) + private async void HandleSay(EntityUid uid, string message, string speaker, Filter? filter) { - if (!_prototypeManager.TryIndex(ev.VoiceId, out var ttsPrototype)) - return; - var message = FormattedMessage.RemoveMarkup(ev.Message); - var soundData = await GenerateTTS(null, message, ttsPrototype.Speaker, speechRate: "slow"); - if (soundData == null) - return; - Filter filter; - if (ev.Global) - filter = Filter.Broadcast(); - else + var soundData = await GenerateTTS(message, speaker); + if (soundData is null) return; + RaiseNetworkEvent(new PlayTTSEvent(soundData, GetNetEntity(uid)), filter ?? Filter.Pvs(uid)); + } + + private async void HandleWhisper(EntityUid uid, string message, string obfMessage, string speaker, Filter? filter) + { + var netEntity = GetNetEntity(uid); + + PlayTTSEvent fullTtsEvent; + PlayTTSEvent? obfTtsEvent = null; + { - var station = _stationSystem.GetOwningStation(ev.Source); - if (station == null || !EntityManager.TryGetComponent(station, out var stationDataComp)) + var fullSoundData = await GenerateTTS(message, speaker, true); + if (fullSoundData is null) return; - filter = _stationSystem.GetInStation(stationDataComp); + fullTtsEvent = new PlayTTSEvent(fullSoundData, netEntity, true); + if (message == obfMessage) + { + obfTtsEvent = fullTtsEvent; + } + else + { + var obfSoundData = await GenerateTTS(obfMessage, speaker, true); + if (obfSoundData is not null) + { + obfTtsEvent = new PlayTTSEvent(obfSoundData, netEntity, true); + } + } } - foreach (var player in filter.Recipients) + // TODO: Check obstacles + var xformQuery = GetEntityQuery(); + var sourcePos = _xforms.GetWorldPosition(xformQuery.GetComponent(uid), xformQuery); + var receptions = (filter ?? Filter.Pvs(uid)).Recipients; + foreach (var session in receptions) { - if (player.AttachedEntity == null) + if (!xformQuery.TryComp(session.AttachedEntity, out var xform)) continue; - // Get emergency lights in range to broadcast from - var entities = _lookup.GetEntitiesInRange(player.AttachedEntity.Value, 30f) - .Where(HasComp) - .ToList(); + var distance = (sourcePos - _xforms.GetWorldPosition(xform, xformQuery)).Length(); + if (distance > ChatSystem.VoiceRange * ChatSystem.VoiceRange) + continue; - if (entities.Count == 0) - return; + if(distance <= ChatSystem.WhisperClearRange) + RaiseNetworkEvent(fullTtsEvent, session); + else if(obfTtsEvent!= null) + RaiseNetworkEvent(obfTtsEvent, session); + } + } - // Get closest emergency light - var entity = entities.First(); - var range = new Vector2(100f); - foreach (var item in entities) - { - var itemSource = _xforms.GetWorldPosition(Transform(item)); - var playerSource = _xforms.GetWorldPosition(Transform(player.AttachedEntity.Value)); + private readonly Dictionary> _ttsTasks = new(); + private readonly SemaphoreSlim _lock = new(1, 1); + + // ReSharper disable once InconsistentNaming + private async Task GenerateTTS(string text, string speaker, bool isWhisper = false) + { + var textSanitized = Sanitize(text); + if (textSanitized == "") return null; + if (char.IsLetter(textSanitized[^1])) + textSanitized += "."; - var distance = playerSource - itemSource; + var ssmlTraits = SoundTraits.RateFast; + if (isWhisper) + ssmlTraits = SoundTraits.PitchVerylow; + var textSsml = ToSsmlText(textSanitized, ssmlTraits); - if (range.Length() <= distance.Length()) - continue; + // Создаем уникальный ключ на основе всех аргументов + var taskKey = $"{textSanitized}_{speaker}_{isWhisper}"; - range = distance; - entity = item; + // Блокируем доступ к словарю, чтобы избежать гонки + await _lock.WaitAsync(); + try + { + // Если задача уже выполняется для этого набора аргументов, ждем её завершения + if (_ttsTasks.TryGetValue(taskKey, out var existingTask)) + { + return await existingTask; } - RaiseNetworkEvent(new PlayTTSEvent(GetNetEntity(entity), soundData, true), Filter.SinglePlayer(player), - false); + // Создаем задачу и сохраняем её в словарь + var newTask = _ttsManager.ConvertTextToSpeech(speaker, textSsml); + _ttsTasks[taskKey] = newTask; + } + finally + { + _lock.Release(); } - } - - private void OnRoundRestartCleanup(RoundRestartCleanupEvent ev) - { - _ttsManager.ResetCache(); - } - - private async void OnRequestTTS(MsgRequestTTS ev) - { - var url = _cfg.GetCVar(WhiteCVars.TTSApiUrl); - if (string.IsNullOrWhiteSpace(url)) - return; - - if (!_playerManager.TryGetSessionByChannel(ev.MsgChannel, out var session) || - !_prototypeManager.TryIndex(ev.VoiceId, out var protoVoice)) - return; - var soundData = await GenerateTTS(GetEntity(ev.Uid), ev.Text, protoVoice.Speaker); - if (soundData != null) - RaiseNetworkEvent(new PlayTTSEvent(ev.Uid, soundData, false), Filter.SinglePlayer(session), false); + try + { + // Ожидаем завершения задачи + return await _ttsTasks[taskKey]; + } + finally + { + // Удаляем задачу из словаря независимо от результата + await _lock.WaitAsync(); + try + { + _ttsTasks.Remove(taskKey); + } + finally + { + _lock.Release(); + } + } } +} - private async Task GenerateTTS(EntityUid? uid, string text, string speaker, string? speechRate = null, string? speechPitch = null) +public sealed class EntitySpokeLanguageEvent: EntityEventArgs +{ + public readonly string? ObfuscatedLangMessage; + public readonly string? LangMessage; + public readonly bool IsWhisper; + public readonly Filter OrgMsg; + public readonly Filter ObsMsg; + public readonly EntityUid Source; + public readonly string Message; + public readonly string OriginalMessage; + public readonly string ObfuscatedMessage; // not null if this was a whisper + + /// + /// If the entity was trying to speak into a radio, this was the channel they were trying to access. If a radio + /// message gets sent on this channel, this should be set to null to prevent duplicate messages. + /// + public RadioChannelPrototype? Channel; + + public EntitySpokeLanguageEvent( + Filter orgMsg, + Filter obsMsg, + EntityUid source, + string message, + string originalMessage, + RadioChannelPrototype? channel, + bool isWhisper, + string obfuscatedMessage, + string? langMessage = null, + string? obfuscatedLangMessage = null) { - var textSanitized = Sanitize(text); - if (textSanitized == "") - return null; - - textSanitized = _ttsPitchRateSystem.GetFormattedSpeechText(uid, textSanitized, speechRate, speechPitch); - return await _ttsManager.ConvertTextToSpeech(speaker, textSanitized); + ObfuscatedLangMessage = obfuscatedLangMessage; + LangMessage = langMessage; + IsWhisper = isWhisper; + OrgMsg = orgMsg; + ObsMsg = obsMsg; + Source = source; + Message = message; + OriginalMessage = originalMessage; + Channel = channel; + ObfuscatedMessage = obfuscatedMessage; } } -public sealed class TransformSpeakerVoiceEvent(EntityUid sender, string voiceId) : EntityEventArgs +public sealed class TransformSpeakerVoiceEvent : EntityEventArgs { - public EntityUid Sender = sender; - public ProtoId VoiceId = voiceId; -} + public EntityUid Sender; + public string VoiceId; -public sealed class TtsAnnouncementEvent(string message, string voiceId, EntityUid source, bool global) : EntityEventArgs -{ - public readonly string Message = message; - public readonly bool Global = global; - public readonly ProtoId VoiceId = voiceId; - public readonly EntityUid Source = source; + public TransformSpeakerVoiceEvent(EntityUid sender, string voiceId) + { + Sender = sender; + VoiceId = voiceId; + } } diff --git a/Content.Server/_White/TTS/VoiceMaskSystem.TTS.cs b/Content.Server/_White/TTS/VoiceMaskSystem.TTS.cs new file mode 100644 index 0000000000..9cae2d4fd7 --- /dev/null +++ b/Content.Server/_White/TTS/VoiceMaskSystem.TTS.cs @@ -0,0 +1,31 @@ +using Content.Server._White.TTS; +using Content.Shared._White.TTS; + +namespace Content.Server.VoiceMask; + +public partial class VoiceMaskSystem +{ + // ReSharper disable once InconsistentNaming + private void InitializeTTS() + { + SubscribeLocalEvent(OnSpeakerVoiceTransform); + SubscribeLocalEvent(OnChangeVoice); + } + + private void OnSpeakerVoiceTransform(EntityUid uid, VoiceMaskComponent component, TransformSpeakerVoiceEvent args) + { + args.VoiceId = component.VoiceId; + } + + private void OnChangeVoice(Entity entity, ref VoiceMaskChangeVoiceMessage msg) + { + if (msg.Voice is { } id && !_proto.HasIndex(id)) + return; + + entity.Comp.VoiceId = msg.Voice; + + _popupSystem.PopupEntity(Loc.GetString("voice-mask-voice-popup-success"), entity); + + UpdateUI(entity); + } +} diff --git a/Content.Shared/Chat/SharedChatSystem.cs b/Content.Shared/Chat/SharedChatSystem.cs index 46ec2ab80d..17b253d322 100644 --- a/Content.Shared/Chat/SharedChatSystem.cs +++ b/Content.Shared/Chat/SharedChatSystem.cs @@ -24,6 +24,11 @@ public abstract class SharedChatSystem : EntitySystem public const char WhisperPrefix = ','; public const char TelepathicPrefix = '='; //Nyano - Summary: Adds the telepathic channel's prefix. public const char DefaultChannelKey = 'h'; + // WD EDIT START + public const int VoiceRange = 10; + public const int WhisperClearRange = 2; + public const int WhisperMuffledRange = 5; + // WD EDIT END [ValidatePrototypeId] public const string CommonChannel = "Common"; diff --git a/Content.Shared/Humanoid/SharedHumanoidAppearanceSystem.cs b/Content.Shared/Humanoid/SharedHumanoidAppearanceSystem.cs index b5d91bdd58..a011a87631 100644 --- a/Content.Shared/Humanoid/SharedHumanoidAppearanceSystem.cs +++ b/Content.Shared/Humanoid/SharedHumanoidAppearanceSystem.cs @@ -47,13 +47,13 @@ public abstract class SharedHumanoidAppearanceSystem : EntitySystem public const string DefaultSpecies = "Human"; // WD EDIT START - [ValidatePrototypeId] - public const string DefaultVoice = "Eugene"; + public const string DefaultVoice = "Aidar"; + public static readonly Dictionary DefaultSexVoice = new() { - {Sex.Male, "Eugene"}, - {Sex.Female, "Kseniya"}, - {Sex.Unsexed, "Xenia"}, + { Sex.Male, "Aidar" }, + { Sex.Female, "Kseniya" }, + { Sex.Unsexed, "Baya" }, }; // WD EDIT END @@ -330,7 +330,7 @@ public void SetTTSVoice( return; humanoid.Voice = voiceId; - comp.Prototype = voiceId; + comp.VoicePrototypeId = voiceId; if (sync) Dirty(uid, humanoid); diff --git a/Content.Shared/Preferences/HumanoidCharacterProfile.cs b/Content.Shared/Preferences/HumanoidCharacterProfile.cs index 5e9e9bd3c5..30ffd673e8 100644 --- a/Content.Shared/Preferences/HumanoidCharacterProfile.cs +++ b/Content.Shared/Preferences/HumanoidCharacterProfile.cs @@ -252,6 +252,7 @@ public static HumanoidCharacterProfile RandomWithSpecies(string species = Shared ).ID; // WD EDIT END + var name = GetName(species, gender); return new HumanoidCharacterProfile() @@ -260,16 +261,16 @@ public static HumanoidCharacterProfile RandomWithSpecies(string species = Shared Sex = sex, Age = age, Gender = gender, - Species = species, Voice = voiceId, // WD EDIT + Species = species, Appearance = HumanoidCharacterAppearance.Random(species, sex), }; } public HumanoidCharacterProfile WithName(string name) => new(this) { Name = name }; public HumanoidCharacterProfile WithFlavorText(string flavorText) => new(this) { FlavorText = flavorText }; - public HumanoidCharacterProfile WithAge(int age) => new(this) { Age = age }; public HumanoidCharacterProfile WithVoice(string voice) => new(this) { Voice = voice }; // WD EDIT + public HumanoidCharacterProfile WithAge(int age) => new(this) { Age = age }; public HumanoidCharacterProfile WithSex(Sex sex) => new(this) { Sex = sex }; public HumanoidCharacterProfile WithGender(Gender gender) => new(this) { Gender = gender }; public HumanoidCharacterProfile WithSpecies(string species) => new(this) { Species = species }; @@ -352,8 +353,8 @@ public bool MemberwiseEquals(ICharacterProfile maybeOther) return maybeOther is HumanoidCharacterProfile other && Name == other.Name && Age == other.Age - && Voice == other.Voice // WD EDIT && Sex == other.Sex + && Voice == other.Voice // WD EDIT && Gender == other.Gender && Species == other.Species && PreferenceUnavailable == other.PreferenceUnavailable diff --git a/Content.Shared/_White/CVars.cs b/Content.Shared/_White/CVars.cs index 6d932d3cdf..80733128fe 100644 --- a/Content.Shared/_White/CVars.cs +++ b/Content.Shared/_White/CVars.cs @@ -31,44 +31,47 @@ public static readonly CVarDef #region TTS - /// - /// if the TTS system enabled or not. + /// + /// URL of the TTS server API. /// - // ReSharper disable once InconsistentNaming - public static readonly CVarDef TTSEnabled = CVarDef.Create("tts.enabled", true, CVar.SERVERONLY); + public static readonly CVarDef TTSEnabled = + CVarDef.Create("tts.enabled", false, CVar.SERVER | CVar.REPLICATED | CVar.ARCHIVE); /// /// URL of the TTS server API. /// - // ReSharper disable once InconsistentNaming - public static readonly CVarDef TTSApiUrl = CVarDef.Create("tts.api_url", "", CVar.SERVERONLY); + public static readonly CVarDef TTSApiUrl = + CVarDef.Create("tts.api_url", "", CVar.SERVERONLY | CVar.ARCHIVE); /// /// Auth token of the TTS server API. /// - // ReSharper disable once InconsistentNaming public static readonly CVarDef TTSApiToken = CVarDef.Create("tts.api_token", "", CVar.SERVERONLY | CVar.CONFIDENTIAL); /// - /// The volume of TTS playback. + /// Amount of seconds before timeout for API /// - // ReSharper disable once InconsistentNaming - public static readonly CVarDef TTSVolume = CVarDef.Create("tts.volume", 0f, CVar.CLIENTONLY | CVar.ARCHIVE); + public static readonly CVarDef TTSApiTimeout = + CVarDef.Create("tts.api_timeout", 5, CVar.SERVERONLY | CVar.ARCHIVE); /// - /// TTS Cache. + /// Default volume setting of TTS sound + /// + public static readonly CVarDef TTSVolume = + CVarDef.Create("tts.volume", 0f, CVar.CLIENTONLY | CVar.ARCHIVE); + + /// + /// Count of in-memory cached tts voice lines. /// - // ReSharper disable once InconsistentNaming public static readonly CVarDef TTSMaxCache = - CVarDef.Create("tts.max_cash_size", 200, CVar.SERVERONLY | CVar.ARCHIVE); + CVarDef.Create("tts.max_cache", 250, CVar.SERVERONLY | CVar.ARCHIVE); /// - /// Amount of seconds before timeout for API + /// VoiceId for Announcement TTS /// - // ReSharper disable once InconsistentNaming - public static readonly CVarDef TTSApiTimeout = - CVarDef.Create("tts.api_timeout", 5, CVar.SERVERONLY | CVar.ARCHIVE); + public static readonly CVarDef TTSAnnounceVoiceId = + CVarDef.Create("tts.announce_voice", "Announcer", CVar.SERVERONLY | CVar.ARCHIVE); #endregion } diff --git a/Content.Shared/_White/TTS/Events/PlayTTSEvent.cs b/Content.Shared/_White/TTS/Events/PlayTTSEvent.cs deleted file mode 100644 index 741a7389a2..0000000000 --- a/Content.Shared/_White/TTS/Events/PlayTTSEvent.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Robust.Shared.Serialization; - -namespace Content.Shared._White.TTS.Events; - -[Serializable, NetSerializable] -// ReSharper disable once InconsistentNaming -public sealed class PlayTTSEvent(NetEntity uid, byte[] data, bool boostVolume) : EntityEventArgs -{ - public NetEntity Uid { get; } = uid; - - public byte[] Data { get; } = data; - - public bool BoostVolume { get; } = boostVolume; -} diff --git a/Content.Shared/_White/TTS/Events/RequestTTSEvent.cs b/Content.Shared/_White/TTS/Events/RequestTTSEvent.cs deleted file mode 100644 index 02ec705c2f..0000000000 --- a/Content.Shared/_White/TTS/Events/RequestTTSEvent.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Robust.Shared.Serialization; - -namespace Content.Shared._White.TTS.Events; - -[Serializable, NetSerializable] -// ReSharper disable once InconsistentNaming -public sealed class RequestTTSEvent(string text) : EntityEventArgs -{ - public string Text { get; } = text; -} diff --git a/Content.Shared/_White/TTS/MsgRequestTTS.cs b/Content.Shared/_White/TTS/MsgRequestTTS.cs deleted file mode 100644 index d5e72be6fa..0000000000 --- a/Content.Shared/_White/TTS/MsgRequestTTS.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Lidgren.Network; -using Robust.Shared.Network; -using Robust.Shared.Prototypes; -using Robust.Shared.Serialization; - -namespace Content.Shared._White.TTS; - -// ReSharper disable once InconsistentNaming -public sealed class MsgRequestTTS : NetMessage -{ - public override MsgGroups MsgGroup => MsgGroups.Command; - - public NetEntity Uid { get; set; } = NetEntity.Invalid; - public string Text { get; set; } = string.Empty; - public ProtoId VoiceId { get; set; } = string.Empty; - - public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer) - { - Uid = new NetEntity(buffer.ReadInt32()); - Text = buffer.ReadString(); - VoiceId = buffer.ReadString(); - } - - public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer serializer) - { - buffer.Write((int)Uid); - buffer.Write(Text); - buffer.Write(VoiceId); - } -} diff --git a/Content.Shared/_White/TTS/TTSComponent.cs b/Content.Shared/_White/TTS/TTSComponent.cs index b180fe3c88..6b328e38a0 100644 --- a/Content.Shared/_White/TTS/TTSComponent.cs +++ b/Content.Shared/_White/TTS/TTSComponent.cs @@ -1,11 +1,19 @@ +using Robust.Shared.GameStates; using Robust.Shared.Prototypes; namespace Content.Shared._White.TTS; -[RegisterComponent, AutoGenerateComponentState] +/// +/// Apply TTS for entity chat say messages +/// +[RegisterComponent, NetworkedComponent] // ReSharper disable once InconsistentNaming public sealed partial class TTSComponent : Component { - [DataField, AutoNetworkedField] - public ProtoId Prototype { get; set; } = "Eugene"; + /// + /// Prototype of used voice for TTS. + /// + [ViewVariables(VVAccess.ReadWrite)] + [DataField("voice")] + public ProtoId? VoicePrototypeId { get; set; } } diff --git a/Content.Shared/_White/TTS/TTSPitchRateSystem.cs b/Content.Shared/_White/TTS/TTSPitchRateSystem.cs deleted file mode 100644 index 6ce460e575..0000000000 --- a/Content.Shared/_White/TTS/TTSPitchRateSystem.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Content.Shared.Humanoid; -using Content.Shared.Humanoid.Prototypes; -using Robust.Shared.Prototypes; - -namespace Content.Shared._White.TTS; - -// ReSharper disable once InconsistentNaming -public sealed class TTSPitchRateSystem : EntitySystem -{ - public readonly Dictionary, TTSPitchRate> SpeciesPitches = new() - { - ["SlimePerson"] = new TTSPitchRate("high"), - ["Arachnid"] = new TTSPitchRate("x-high", "x-fast"), - ["Dwarf"] = new TTSPitchRate("high", "slow"), - ["Human"] = new TTSPitchRate(), - ["Diona"] = new TTSPitchRate("x-low", "x-slow"), - ["Reptilian"] = new TTSPitchRate("low", "slow"), - }; - - public string GetFormattedSpeechText(EntityUid? uid, string text, string? speechRate = null, string? speechPitch = null) - { - var ssml = text; - if (TryComp(uid, out var humanoid)) - { - var species = SpeciesPitches.GetValueOrDefault(humanoid.Species); - if (species != null) - { - speechRate ??= species.Rate; - speechPitch ??= species.Pitch; - } - } - - if (speechRate != null) - ssml = $"{ssml}"; - if (speechPitch != null) - ssml = $"{ssml}"; - - return $"{ssml}"; - } -} - -// ReSharper disable once InconsistentNaming -public record TTSPitchRate(string Pitch = "medium", string Rate = "medium"); diff --git a/Content.Shared/_White/TTS/TTSSerializable.cs b/Content.Shared/_White/TTS/TTSSerializable.cs new file mode 100644 index 0000000000..6d36a577e2 --- /dev/null +++ b/Content.Shared/_White/TTS/TTSSerializable.cs @@ -0,0 +1,49 @@ +using Robust.Shared.Audio; +using Robust.Shared.Serialization; + +namespace Content.Shared._White.TTS; + +public enum VoiceRequestType +{ + None, + Preview +} + +[Serializable, NetSerializable] +// ReSharper disable once InconsistentNaming +public sealed class PlayTTSEvent(byte[] data, NetEntity? sourceUid = null, bool isWhisper = false) : EntityEventArgs +{ + public byte[] Data { get; } = data; + public NetEntity? SourceUid { get; } = sourceUid; + public bool IsWhisper { get; } = isWhisper; +} + +// ReSharper disable once InconsistentNaming +[Serializable, NetSerializable] +public sealed class RequestGlobalTTSEvent(VoiceRequestType text, string voiceId) : EntityEventArgs +{ + public VoiceRequestType Text { get;} = text; + public string VoiceId { get; } = voiceId; +} + +// ReSharper disable once InconsistentNaming +[Serializable, NetSerializable] +public sealed class RequestPreviewTTSEvent(string voiceId) : EntityEventArgs +{ + public string VoiceId { get; } = voiceId; +} + +[Serializable, NetSerializable] +public sealed class VoiceMaskChangeVoiceMessage(string voice) : BoundUserInterfaceMessage +{ + public string Voice = voice; +} + +// ReSharper disable once InconsistentNaming +[Serializable, NetSerializable] +public sealed class AnnounceTTSEvent(byte[] data, string announcementSound, AudioParams announcementParams) : EntityEventArgs +{ + public byte[] Data { get; } = data; + public string AnnouncementSound { get; } = announcementSound; + public AudioParams AnnouncementParams{ get; } = announcementParams; +} diff --git a/Content.Shared/_White/TTS/TTSVoicePrototype.cs b/Content.Shared/_White/TTS/TTSVoicePrototype.cs index 976d0f5157..7838db1ed4 100644 --- a/Content.Shared/_White/TTS/TTSVoicePrototype.cs +++ b/Content.Shared/_White/TTS/TTSVoicePrototype.cs @@ -1,33 +1,31 @@ -using Content.Shared.Humanoid; +using Content.Shared.Humanoid; using Robust.Shared.Prototypes; namespace Content.Shared._White.TTS; +/// +/// Prototype represent available TTS voices +/// [Prototype("ttsVoice")] // ReSharper disable once InconsistentNaming -public sealed class TTSVoicePrototype : IPrototype +public sealed partial class TTSVoicePrototype : IPrototype { [IdDataField] public string ID { get; } = default!; - [DataField] + [DataField("name")] public string Name { get; } = string.Empty; - [DataField(required: true)] - public Sex Sex { get; } + [DataField("sex", required: true)] + public Sex Sex { get; } = default!; - [ViewVariables(VVAccess.ReadWrite), DataField(required: true)] + [ViewVariables(VVAccess.ReadWrite)] + [DataField("speaker", required: true)] public string Speaker { get; } = string.Empty; /// - /// Whether the voice is available in the character editor. + /// Whether the species is available "at round start" (In the character editor) /// - [DataField] + [DataField("roundStart")] public bool RoundStart { get; } = true; - - [DataField] - public bool SponsorOnly { get; } - - [DataField] - public bool BorgVoice { get; } } diff --git a/Resources/Locale/en-US/_white/escape-menu/ui/options-menu.ftl b/Resources/Locale/en-US/_white/escape-menu/options-menu.ftl similarity index 100% rename from Resources/Locale/en-US/_white/escape-menu/ui/options-menu.ftl rename to Resources/Locale/en-US/_white/escape-menu/options-menu.ftl diff --git a/Resources/Locale/en-US/_white/prototypes/voice/tts_voices.ftl b/Resources/Locale/en-US/_white/prototypes/voice/tts-voices.ftl similarity index 97% rename from Resources/Locale/en-US/_white/prototypes/voice/tts_voices.ftl rename to Resources/Locale/en-US/_white/prototypes/voice/tts-voices.ftl index 2abf6c60ef..77493c2ebf 100644 --- a/Resources/Locale/en-US/_white/prototypes/voice/tts_voices.ftl +++ b/Resources/Locale/en-US/_white/prototypes/voice/tts-voices.ftl @@ -42,4 +42,4 @@ tts-voice-name-jake = Jake tts-voice-name-rihter = Rihter tts-voice-name-wane = Wane tts-voice-name-aidar = Aidar -tts-voice-name-baya = Baya +tts-voice-name-baya = Baya \ No newline at end of file diff --git a/Resources/Locale/ru-RU/_white/escape-menu/ui/options-menu.ftl b/Resources/Locale/ru-RU/_white/escape-menu/options-menu.ftl similarity index 100% rename from Resources/Locale/ru-RU/_white/escape-menu/ui/options-menu.ftl rename to Resources/Locale/ru-RU/_white/escape-menu/options-menu.ftl diff --git a/Resources/Locale/ru-RU/_white/prototypes/voice/tts_voices.ftl b/Resources/Locale/ru-RU/_white/prototypes/voice/tts-voices.ftl similarity index 100% rename from Resources/Locale/ru-RU/_white/prototypes/voice/tts_voices.ftl rename to Resources/Locale/ru-RU/_white/prototypes/voice/tts-voices.ftl diff --git a/Resources/Prototypes/Entities/Mobs/Species/base.yml b/Resources/Prototypes/Entities/Mobs/Species/base.yml index ff8a16a467..0c5c4b1fb0 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/base.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/base.yml @@ -230,7 +230,7 @@ - DoorBumpOpener - type: Targeting - type: SurgeryTarget - - type: TTS + - type: TTS # WD EDIT - type: entity save: false