From 01930373c6c70515b204c722e28d2fc0911aa174 Mon Sep 17 00:00:00 2001
From: ReWAFFlution <153686236+ReWAFFlution@users.noreply.github.com>
Date: Sun, 22 Mar 2026 22:38:08 +0200
Subject: [PATCH 1/2] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=BD=D0=BE=D1=81?=
=?UTF-8?q?=20#1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Lobby/UI/HumanoidProfileEditor.xaml | 13 ++
.../Lobby/UI/HumanoidProfileEditor.xaml.cs | 12 ++
Content.Client/Options/UI/Tabs/AudioTab.xaml | 2 +
.../Options/UI/Tabs/AudioTab.xaml.cs | 9 +
.../VoiceMask/VoiceMaskBoundUserInterface.cs | 3 +-
.../VoiceMask/VoiceMaskNameChangeWindow.xaml | 10 +
.../VoiceMaskNameChangeWindow.xaml.cs | 9 +-
Content.Client/_Art/TTS/ContentAudioSystem.cs | 8 +
.../_Art/TTS/HumanoidProfileEditor.TTS.cs | 71 +++++++
Content.Client/_Art/TTS/TTSSystem.cs | 191 ++++++++++++++++++
.../TTS/VoiceMaskNameChangeWindow.xaml.cs | 42 ++++
Content.Server/Entry/EntryPoint.cs | 3 +
Content.Server/IoC/ServerContentIoC.cs | 2 +
Content.Server/VoiceMask/VoiceMaskSystem.cs | 5 +-
14 files changed, 376 insertions(+), 4 deletions(-)
create mode 100644 Content.Client/_Art/TTS/ContentAudioSystem.cs
create mode 100644 Content.Client/_Art/TTS/HumanoidProfileEditor.TTS.cs
create mode 100644 Content.Client/_Art/TTS/TTSSystem.cs
create mode 100644 Content.Client/_Art/TTS/VoiceMaskNameChangeWindow.xaml.cs
diff --git a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml
index 84c5b75d1cb..b4af1bc5760 100644
--- a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml
+++ b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml
@@ -92,7 +92,20 @@
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs
index 3b444bf4162..b78fe1a8676 100644
--- a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs
+++ b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs
@@ -2,6 +2,7 @@
using Content.Client.Message;
using Content.Client.Players.PlayTimeTracking;
using Content.Client.Sprite;
+using Content.Shared._Art.ArtCVar; // Art-TTS
using Content.Shared.CCVar;
using Content.Shared.GameTicking;
using Content.Shared.Humanoid;
@@ -191,6 +192,14 @@ public HumanoidProfileEditor(
#endregion Gender
+ // Art-TTS End
+ if (configurationManager.GetCVar(ArtCVars.TTSEnabled))
+ {
+ TTSContainer.Visible = true;
+ InitializeVoice();
+ }
+ // Art-TTS End
+
RefreshSpecies();
SpeciesButton.OnItemSelected += args =>
@@ -288,6 +297,8 @@ public HumanoidProfileEditor(
RefreshFlavorText();
+ UpdateTtsVoicesControls(); // Art-TTS
+
#region Dummy
SpriteRotateLeft.OnPressed += _ =>
@@ -366,6 +377,7 @@ public void SetProfile(HumanoidCharacterProfile? profile, int? slot)
IsDirty = false;
JobOverride = null;
+ UpdateTTSVoicesControls(); // Art-TTS
UpdateNameEdit();
UpdateFlavorTextEdit();
UpdateSexControls();
diff --git a/Content.Client/Options/UI/Tabs/AudioTab.xaml b/Content.Client/Options/UI/Tabs/AudioTab.xaml
index 5764755bb9a..47c9cc8861b 100644
--- a/Content.Client/Options/UI/Tabs/AudioTab.xaml
+++ b/Content.Client/Options/UI/Tabs/AudioTab.xaml
@@ -8,6 +8,7 @@
+
@@ -15,6 +16,7 @@
+
diff --git a/Content.Client/Options/UI/Tabs/AudioTab.xaml.cs b/Content.Client/Options/UI/Tabs/AudioTab.xaml.cs
index d57f36e74f8..c8e1b36c5d9 100644
--- a/Content.Client/Options/UI/Tabs/AudioTab.xaml.cs
+++ b/Content.Client/Options/UI/Tabs/AudioTab.xaml.cs
@@ -1,5 +1,6 @@
using Content.Client.Administration.Managers;
using Content.Client.Audio;
+using Content.Shared._Art.ArtCVar; // Art-TTS
using Content.Shared.CCVar;
using Robust.Client.Audio;
using Robust.Client.AutoGenerated;
@@ -28,6 +29,13 @@ public AudioTab()
scale: ContentAudioSystem.MasterVolumeMultiplier);
masterVolume.ImmediateValueChanged += OnMasterVolumeSliderChanged;
+ // Art-TTS Start
+ Control.AddOptionPercentSlider(
+ ArtCVars.TTSVolume,
+ SliderVolumeTts,
+ scale: ContentAudioSystem.TtsMultiplier);
+ // Art-TTS End
+
Control.AddOptionPercentSlider(
CVars.MidiVolume,
SliderVolumeMidi,
@@ -59,6 +67,7 @@ public AudioTab()
_cfg.GetCVar(CCVars.MinMaxAmbientSourcesConfigured),
_cfg.GetCVar(CCVars.MaxMaxAmbientSourcesConfigured));
+ Control.AddOptionCheckBox(ArtCVars.TTSClientEnabled, TtsClientCheckBox); // Art-TTS
Control.AddOptionCheckBox(CCVars.LobbyMusicEnabled, LobbyMusicCheckBox);
Control.AddOptionCheckBox(CCVars.RestartSoundsEnabled, RestartSoundsCheckBox);
Control.AddOptionCheckBox(CCVars.EventMusicEnabled, EventMusicCheckBox);
diff --git a/Content.Client/VoiceMask/VoiceMaskBoundUserInterface.cs b/Content.Client/VoiceMask/VoiceMaskBoundUserInterface.cs
index b2b374cac5b..c0517414c2f 100644
--- a/Content.Client/VoiceMask/VoiceMaskBoundUserInterface.cs
+++ b/Content.Client/VoiceMask/VoiceMaskBoundUserInterface.cs
@@ -28,6 +28,7 @@ protected override void Open()
_window.OnVerbChange += verb => SendMessage(new VoiceMaskChangeVerbMessage(verb));
_window.OnToggle += OnToggle;
_window.OnAccentToggle += OnAccentToggle;
+ _window.OnVoiceChange += voice => SendMessage(new VoiceMaskChangeVoiceMessage(voice)); // Art-TTS
}
private void OnNameSelected(string name)
@@ -52,7 +53,7 @@ protected override void UpdateState(BoundUserInterfaceState state)
return;
}
- _window.UpdateState(cast.Name, cast.Verb, cast.Active, cast.AccentHide);
+ _window.UpdateState(cast.Name, cast.Voice, cast.Verb, cast.Active, cast.AccentHide); // Art-TTS
}
protected override void Dispose(bool disposing)
diff --git a/Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml b/Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml
index 18416757b9e..0c54ffb8019 100644
--- a/Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml
+++ b/Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml
@@ -14,5 +14,15 @@
+
+
+
+
+
+
+
diff --git a/Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml.cs b/Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml.cs
index a5e70362831..4cd06fc6497 100644
--- a/Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml.cs
+++ b/Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml.cs
@@ -28,6 +28,8 @@ public VoiceMaskNameChangeWindow()
OnNameChange?.Invoke(NameSelector.Text);
};
+ ReloadVoices(); // Art-TTS
+
SpeechVerbSelector.OnItemSelected += args =>
{
OnVerbChange?.Invoke((string?) args.Button.GetItemMetadata(args.Id));
@@ -69,7 +71,7 @@ private void AddVerb(string name, string? verb)
SpeechVerbSelector.SelectId(id);
}
- public void UpdateState(string name, string? verb, bool active, bool accentHide)
+ public void UpdateState(string name, string voice, string? verb, bool active, bool accentHide) // Art-TTS
{
NameSelector.Text = name;
_verb = verb;
@@ -84,5 +86,10 @@ public void UpdateState(string name, string? verb, bool active, bool accentHide)
break;
}
}
+ // Art-TTS Start
+ var voiceIdx = _voices.FindIndex(v => v.ID == voice);
+ if (voiceIdx != -1)
+ VoiceSelector.Select(voiceIdx);
+ // Art-TTS End
}
}
diff --git a/Content.Client/_Art/TTS/ContentAudioSystem.cs b/Content.Client/_Art/TTS/ContentAudioSystem.cs
new file mode 100644
index 00000000000..fe11a1ed336
--- /dev/null
+++ b/Content.Client/_Art/TTS/ContentAudioSystem.cs
@@ -0,0 +1,8 @@
+using Content.Shared.Audio;
+
+namespace Content.Client.Audio;
+
+public sealed partial class ContentAudioSystem : SharedContentAudioSystem
+{
+ public const float TtsMultiplier = 3f;
+}
diff --git a/Content.Client/_Art/TTS/HumanoidProfileEditor.TTS.cs b/Content.Client/_Art/TTS/HumanoidProfileEditor.TTS.cs
new file mode 100644
index 00000000000..c5d3b57e147
--- /dev/null
+++ b/Content.Client/_Art/TTS/HumanoidProfileEditor.TTS.cs
@@ -0,0 +1,71 @@
+using System.Linq;
+using Content.Client._Art.TTS;
+using Content.Client.Lobby;
+// using Content.Corvax.Interfaces.Shared;
+using Content.Shared._Art.TTS;
+using Content.Shared.Preferences;
+
+namespace Content.Client.Lobby.UI;
+
+public sealed partial class HumanoidProfileEditor
+{
+ private List _voiceList = new();
+
+ private void InitializeVoice()
+ {
+ _voiceList = _prototypeManager
+ .EnumeratePrototypes()
+ .OrderBy(o => Loc.GetString(o.Name))
+ .OrderBy(o => ((o.Gender == "male") ? 0b01 : 0) + ((o.Gender == "female") ? 0b10 : 0))
+ .ToList();
+
+ VoiceButton.OnItemSelected += args =>
+ {
+ VoiceButton.SelectId(args.Id);
+ SetVoice(_voiceList[args.Id].ID);
+ };
+
+ VoicePlayButton.OnPressed += _ => PlayPreviewTTS();
+ }
+
+ private void UpdateTTSVoicesControls()
+ {
+ if (Profile is null)
+ return;
+
+ VoiceButton.Clear();
+
+ var firstVoiceChoiceId = 1;
+ for (var i = 0; i < _voiceList.Count; i++)
+ {
+ var voice = _voiceList[i];
+
+ var name = Loc.GetString(voice.Name);
+ VoiceButton.AddItem(name, i);
+
+ if (firstVoiceChoiceId == 1)
+ firstVoiceChoiceId = i;
+ }
+
+ var voiceChoiceId = _voiceList.FindIndex(x => x.ID == Profile.Voice);
+ if (!VoiceButton.TrySelectId(voiceChoiceId) &&
+ VoiceButton.TrySelectId(firstVoiceChoiceId))
+ {
+ SetVoice(_voiceList[firstVoiceChoiceId].ID);
+ }
+ }
+
+ private void PlayPreviewTTS()
+ {
+ if (Profile is null)
+ return;
+
+ _entManager.System().RequestPreviewTTS(Profile.Voice);
+ }
+
+ private void SetVoice(string newVoice)
+ {
+ Profile = Profile?.WithVoice(newVoice);
+ IsDirty = true;
+ }
+}
diff --git a/Content.Client/_Art/TTS/TTSSystem.cs b/Content.Client/_Art/TTS/TTSSystem.cs
new file mode 100644
index 00000000000..91d93de6e2e
--- /dev/null
+++ b/Content.Client/_Art/TTS/TTSSystem.cs
@@ -0,0 +1,191 @@
+using Content.Shared.Chat;
+using Content.Shared._Art.ACVar;
+using Content.Shared._Art.TTS;
+using Robust.Client.Audio;
+using Robust.Shared.Audio.Components;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.Audio;
+using Robust.Shared.Configuration;
+using Robust.Shared.Utility;
+using System.IO;
+
+namespace Content.Client._Art.TTS;
+
+internal record struct TTSQueueElem(AudioStream Audio, bool IsWhisper, NetEntity Source);
+
+///
+/// Plays TTS audio in world
+///
+// ReSharper disable once InconsistentNaming
+public sealed class TTSSystem : EntitySystem
+{
+ [Dependency] private readonly IConfigurationManager _cfg = default!;
+ [Dependency] private readonly AudioSystem _audio = default!;
+ [Dependency] private readonly IAudioManager _audioLoader = default!;
+
+ private ISawmill _sawmill = default!;
+ private bool _enabled = true;
+
+ ///
+ /// Reducing the volume of the TTS when whispering. Will be converted to logarithm.
+ ///
+ private const float WhisperFade = 4f;
+
+ ///
+ /// The volume at which the TTS sound will not be heard.
+ ///
+ private const float MinimalVolume = -10f;
+
+ ///
+ /// Maximum queued tts talks per entity.
+ ///
+ private const int MaxQueuedSounds = 20;
+
+ private float _volume = 0.0f;
+ internal List _toDelete = new();
+
+ // Author -> Queue of sounds from different sources
+ private Dictionary> _queue = new();
+ // Author -> currently playing sound
+ private Dictionary _playing = new();
+
+ public override void Initialize()
+ {
+ _sawmill = Logger.GetSawmill("tts");
+ _cfg.OnValueChanged(ArtCVars.TTSVolume, OnTtsVolumeChanged, true);
+ _cfg.OnValueChanged(ArtCVars.TTSClientEnabled, OnTtsClientOptionChanged, true);
+ SubscribeNetworkEvent(OnPlayTTS);
+ }
+
+ public override void Shutdown()
+ {
+ base.Shutdown();
+ _cfg.UnsubValueChanged(ArtCVars.TTSVolume, OnTtsVolumeChanged);
+ _cfg.UnsubValueChanged(ArtCVars.TTSClientEnabled, OnTtsClientOptionChanged);
+ }
+
+ public override void FrameUpdate(float frameTime)
+ {
+ if (!_enabled) return;
+ _toDelete.Clear();
+ foreach (var (uid, comp) in _playing)
+ {
+ if (comp != null && !comp.Deleted)
+ {
+ if (!comp.Playing)
+ _toDelete.Add(uid);
+ }
+ else
+ {
+ _toDelete.Add(uid);
+ }
+ }
+ foreach (var uid in _toDelete)
+ {
+ _playing.Remove(uid);
+ }
+
+ _toDelete.Clear();
+ foreach (var (author, queue) in _queue)
+ {
+ if (queue.Count <= 0)
+ { // If author doesn't want to tell anything, ignore it.
+ _toDelete.Add(author);
+ continue;
+ }
+ if (_playing.ContainsKey(author)) continue; // If author is still talking right now.
+ if (!queue.TryDequeue(out var elem)) continue; // Just in case if queue cleared.
+ if (!TryGetEntity(elem.Source, out var local_source))
+ { // If entity is outside PVS.
+ continue;
+ }
+ _playing[author] = PlayTTSFromUid(local_source, elem.Audio, elem.IsWhisper);
+ }
+ foreach (var author in _toDelete)
+ {
+ _queue.Remove(author);
+ }
+ }
+
+ public AudioComponent? PlayTTSFromUid(EntityUid? uid, AudioStream audioStream, bool isWhisper)
+ {
+ var audioParams = AudioParams.Default
+ .WithVolume(AdjustVolume(isWhisper))
+ .WithMaxDistance(AdjustDistance(isWhisper));
+ (EntityUid Entity, AudioComponent Component)? stream;
+
+ _sawmill.Verbose($"Playing TTS audio {audioStream.Length} bytes from {uid} entity");
+
+ if (uid is not null)
+ {
+ stream = _audio.PlayEntity(audioStream, uid.Value, null, audioParams);
+ }
+ else
+ {
+ stream = _audio.PlayGlobal(audioStream, null, audioParams);
+ }
+
+ return stream?.Component;
+ }
+
+ public void RequestPreviewTTS(string voiceId)
+ {
+ RaiseNetworkEvent(new RequestPreviewTTSEvent(voiceId));
+ }
+
+ private void OnTtsClientOptionChanged(bool option)
+ {
+ _enabled = option;
+ RaiseNetworkEvent(new ClientOptionTTSEvent(option));
+ }
+
+ private void OnTtsVolumeChanged(float volume)
+ {
+ _volume = volume;
+ }
+
+ private void OnPlayTTS(PlayTTSEvent ev)
+ {
+ if (!_enabled) return;
+ var source = ev.SourceUid ?? NetEntity.Invalid;
+ var author = ev.Author ?? source;
+ if (!_queue.ContainsKey(author))
+ _queue[author] = new();
+
+ if (_queue[author].Count >= MaxQueuedSounds)
+ return;
+
+ var audioStream = _audioLoader.LoadAudioWav(new MemoryStream(ev.Data));
+
+ if (!author.Valid)
+ {
+ PlayTTSFromUid(null, audioStream, ev.IsWhisper);
+ }
+ else
+ {
+ _queue[author].Enqueue(new TTSQueueElem
+ {
+ Audio = audioStream,
+ IsWhisper = ev.IsWhisper,
+ Source = source,
+ });
+ }
+ }
+
+ private float AdjustVolume(bool isWhisper)
+ {
+ var volume = MinimalVolume + SharedAudioSystem.GainToVolume(_volume * 3.0f);
+
+ if (isWhisper)
+ {
+ volume -= SharedAudioSystem.GainToVolume(WhisperFade);
+ }
+
+ return volume;
+ }
+
+ private float AdjustDistance(bool isWhisper)
+ {
+ return isWhisper ? TTSConfig.WhisperMuffledRange : TTSConfig.VoiceRange;
+ }
+}
diff --git a/Content.Client/_Art/TTS/VoiceMaskNameChangeWindow.xaml.cs b/Content.Client/_Art/TTS/VoiceMaskNameChangeWindow.xaml.cs
new file mode 100644
index 00000000000..a21e996dd5e
--- /dev/null
+++ b/Content.Client/_Art/TTS/VoiceMaskNameChangeWindow.xaml.cs
@@ -0,0 +1,42 @@
+using System.Linq;
+using Content.Shared._Art.ACVar;
+using Robust.Shared.Configuration;
+using Robust.Shared.Prototypes;
+using Content.Shared._Art.TTS;
+using Content.Client.UserInterface.Controls;
+
+namespace Content.Client.VoiceMask;
+
+public sealed partial class VoiceMaskNameChangeWindow : FancyWindow
+{
+ public Action? OnVoiceChange;
+ private List _voices = new();
+ [Dependency] private readonly IPrototypeManager _proto = default!;
+ [Dependency] private readonly IConfigurationManager _cfg = default!;
+
+ private void ReloadVoices()
+ {
+ if (_cfg is null)
+ return;
+ TTSContainer.Visible = _cfg.GetCVar(ArtCVars.TTSEnabled);
+ if (!_cfg.GetCVar(ArtCVars.TTSEnabled))
+ return;
+ VoiceSelector.OnItemSelected += args =>
+ {
+ VoiceSelector.SelectId(args.Id);
+ if (VoiceSelector.SelectedMetadata != null)
+ OnVoiceChange?.Invoke((string)VoiceSelector.SelectedMetadata);
+ };
+ _voices = _proto
+ .EnumeratePrototypes()
+ .OrderBy(o => Loc.GetString(o.Name))
+ .OrderBy(o => ((o.Gender == "male") ? 0b01 : 0) + ((o.Gender == "female") ? 0b10 : 0))
+ .ToList();
+ for (var i = 0; i < _voices.Count; i++)
+ {
+ var name = Loc.GetString(_voices[i].Name);
+ VoiceSelector.AddItem(name);
+ VoiceSelector.SetItemMetadata(i, _voices[i].ID);
+ }
+ }
+}
diff --git a/Content.Server/Entry/EntryPoint.cs b/Content.Server/Entry/EntryPoint.cs
index fb6d3e282d9..8cf8a22e992 100644
--- a/Content.Server/Entry/EntryPoint.cs
+++ b/Content.Server/Entry/EntryPoint.cs
@@ -1,4 +1,5 @@
using System.Threading.Tasks;
+using Content.Server._Art.TTS; // Art-TTS
using Content.Server.Acz;
using Content.Server.Administration;
using Content.Server.Administration.Logs;
@@ -81,6 +82,7 @@ public sealed class EntryPoint : GameServer
[Dependency] private readonly ServerInfoManager _serverInfo = default!;
[Dependency] private readonly ServerUpdateManager _updateManager = default!;
[Dependency] private readonly ServerFeedbackManager _feedbackManager = null!;
+ [Dependency] private readonly TTSManager _ttsManager = default!; // Art-TTS
public override void PreInit()
{
@@ -137,6 +139,7 @@ public override void Init()
_watchlistWebhookManager.Initialize();
_job.Initialize();
_rateLimit.Initialize();
+ _ttsManager.Initialize(); // Art-TTS
}
public override void PostInit()
diff --git a/Content.Server/IoC/ServerContentIoC.cs b/Content.Server/IoC/ServerContentIoC.cs
index 1c6d940e20f..1cead6b95d6 100644
--- a/Content.Server/IoC/ServerContentIoC.cs
+++ b/Content.Server/IoC/ServerContentIoC.cs
@@ -1,3 +1,4 @@
+using Content.Server._Art.TTS; // Art-TTS
using Content.Server.Administration;
using Content.Server.Administration.Logs;
using Content.Server.Administration.Managers;
@@ -40,6 +41,7 @@ internal static class ServerContentIoC
public static void Register(IDependencyCollection deps)
{
SharedContentIoC.Register(deps);
+ deps.Register(); // Art-TTS
deps.Register();
deps.Register();
deps.Register();
diff --git a/Content.Server/VoiceMask/VoiceMaskSystem.cs b/Content.Server/VoiceMask/VoiceMaskSystem.cs
index a67bfb8b669..72072244402 100644
--- a/Content.Server/VoiceMask/VoiceMaskSystem.cs
+++ b/Content.Server/VoiceMask/VoiceMaskSystem.cs
@@ -52,6 +52,7 @@ public override void Initialize()
SubscribeLocalEvent(OnTransformSpeech, before: [typeof(AccentSystem)]);
SubscribeLocalEvent>(OnTransformSpeechInventory, before: [typeof(AccentSystem)]);
SubscribeLocalEvent>(OnTransformSpeechImplant, before: [typeof(AccentSystem)]);
+ InitializeTTS(); // Art-TTS
Subs.CVar(_cfgManager, CCVars.MaxNameLength, value => _maxNameLength = value, true);
}
@@ -191,11 +192,11 @@ private void OpenUI(VoiceMaskSetNameEvent ev)
private void UpdateUI(Entity entity)
{
if (_uiSystem.HasUi(entity, VoiceMaskUIKey.Key))
- _uiSystem.SetUiState(entity.Owner, VoiceMaskUIKey.Key, new VoiceMaskBuiState(GetCurrentVoiceName(entity), entity.Comp.VoiceMaskSpeechVerb, entity.Comp.Active, entity.Comp.AccentHide));
+ _uiSystem.SetUiState(entity.Owner, VoiceMaskUIKey.Key, new VoiceMaskBuiState(GetCurrentVoiceName(entity), entity.Comp.VoiceId, entity.Comp.VoiceMaskSpeechVerb, entity.Comp.Active, entity.Comp.AccentHide)); // Art-TTS
}
#endregion
- #region Helper functions
+ #region Helper functions
private string GetCurrentVoiceName(Entity entity)
{
return entity.Comp.VoiceMaskName ?? Loc.GetString("voice-mask-default-name-override");
From 1d18c7d8c4836df291bea9f53b17a68ec71571c0 Mon Sep 17 00:00:00 2001
From: ReWAFFlution <153686236+ReWAFFlution@users.noreply.github.com>
Date: Mon, 23 Mar 2026 19:53:37 +0200
Subject: [PATCH 2/2] Port TTS #2
---
.../UI/HumanoidProfileEditor.Appearance.cs | 2 +
.../Lobby/UI/HumanoidProfileEditor.xaml.cs | 4 +-
.../VoiceMask/VoiceMaskBoundUserInterface.cs | 2 +-
Content.Client/_Art/TTS/TTSSystem.cs | 2 +-
.../TTS/VoiceMaskNameChangeWindow.xaml.cs | 6 +-
Content.Server.Database/Model.cs | 1 +
Content.Server/Database/ServerDbBase.cs | 1 +
.../Managers/ServerPreferencesManager.cs | 11 +-
.../Radio/EntitySystems/HeadsetSystem.cs | 10 +
.../Radio/EntitySystems/RadioSystem.cs | 15 +-
Content.Server/Radio/RadioEvent.cs | 2 +-
Content.Server/VoiceMask/VoiceMaskSystem.cs | 4 +-
Content.Server/_Art/TTS/TTSManager.cs | 214 ++
.../_Art/TTS/TTSSystem.RateLimit.cs | 35 +
Content.Server/_Art/TTS/TTSSystem.SSML.cs | 23 +
Content.Server/_Art/TTS/TTSSystem.Sanitize.cs | 340 +++
Content.Server/_Art/TTS/TTSSystem.cs | 169 ++
.../_Art/TTS/VoiceMaskSystem.TTS.cs | 32 +
.../Humanoid/HumanoidProfileComponent.cs | 6 +
.../Humanoid/HumanoidProfileExportV1.cs | 9 +-
.../Inventory/InventorySystem.Relay.cs | 3 +
.../Preferences/HumanoidCharacterProfile.cs | 36 +-
.../VoiceMask/SharedVoiceMaskSystem.cs | 4 +-
.../VoiceMask/VoiceMaskComponent.cs | 7 +
Content.Shared/_Art/CVars/ArtCVars.cs | 85 +
.../_Art/TTS/ClientOptionTTSEvent.cs | 13 +
Content.Shared/_Art/TTS/PlayTTSEvent.cs | 28 +
.../_Art/TTS/RequestPreviewTTSEvent.cs | 10 +
.../_Art/TTS/SharedVoiceMaskSystem.cs | 14 +
Content.Shared/_Art/TTS/TTSComponent.cs | 33 +
Content.Shared/_Art/TTS/TTSConfig.cs | 17 +
Content.Shared/_Art/TTS/TTSRadioPlayEvent.cs | 21 +
Content.Shared/_Art/TTS/TTSVoicePrototype.cs | 25 +
Resources/_Art/TTS/tts-voices.yml | 1960 +++++++++++++++++
34 files changed, 3127 insertions(+), 17 deletions(-)
create mode 100644 Content.Server/_Art/TTS/TTSManager.cs
create mode 100644 Content.Server/_Art/TTS/TTSSystem.RateLimit.cs
create mode 100644 Content.Server/_Art/TTS/TTSSystem.SSML.cs
create mode 100644 Content.Server/_Art/TTS/TTSSystem.Sanitize.cs
create mode 100644 Content.Server/_Art/TTS/TTSSystem.cs
create mode 100644 Content.Server/_Art/TTS/VoiceMaskSystem.TTS.cs
create mode 100644 Content.Shared/_Art/CVars/ArtCVars.cs
create mode 100644 Content.Shared/_Art/TTS/ClientOptionTTSEvent.cs
create mode 100644 Content.Shared/_Art/TTS/PlayTTSEvent.cs
create mode 100644 Content.Shared/_Art/TTS/RequestPreviewTTSEvent.cs
create mode 100644 Content.Shared/_Art/TTS/SharedVoiceMaskSystem.cs
create mode 100644 Content.Shared/_Art/TTS/TTSComponent.cs
create mode 100644 Content.Shared/_Art/TTS/TTSConfig.cs
create mode 100644 Content.Shared/_Art/TTS/TTSRadioPlayEvent.cs
create mode 100644 Content.Shared/_Art/TTS/TTSVoicePrototype.cs
create mode 100644 Resources/_Art/TTS/tts-voices.yml
diff --git a/Content.Client/Lobby/UI/HumanoidProfileEditor.Appearance.cs b/Content.Client/Lobby/UI/HumanoidProfileEditor.Appearance.cs
index ddc9752b1ed..5b8a1cdec3d 100644
--- a/Content.Client/Lobby/UI/HumanoidProfileEditor.Appearance.cs
+++ b/Content.Client/Lobby/UI/HumanoidProfileEditor.Appearance.cs
@@ -215,6 +215,8 @@ private void SetSex(Sex newSex)
break;
}
+ UpdateTTSVoicesControls(); // Art-TTS
+
UpdateGenderControls();
_markingsModel.SetOrganSexes(newSex);
ReloadPreview();
diff --git a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs
index b78fe1a8676..3a83a41bb81 100644
--- a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs
+++ b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs
@@ -193,7 +193,7 @@ public HumanoidProfileEditor(
#endregion Gender
// Art-TTS End
- if (configurationManager.GetCVar(ArtCVars.TTSEnabled))
+ if (configurationManager.GetCVar(ArtCVars.TTSClientEnabled))
{
TTSContainer.Visible = true;
InitializeVoice();
@@ -297,7 +297,7 @@ public HumanoidProfileEditor(
RefreshFlavorText();
- UpdateTtsVoicesControls(); // Art-TTS
+ UpdateTTSVoicesControls(); // Art-TTS
#region Dummy
diff --git a/Content.Client/VoiceMask/VoiceMaskBoundUserInterface.cs b/Content.Client/VoiceMask/VoiceMaskBoundUserInterface.cs
index c0517414c2f..c45e69101d7 100644
--- a/Content.Client/VoiceMask/VoiceMaskBoundUserInterface.cs
+++ b/Content.Client/VoiceMask/VoiceMaskBoundUserInterface.cs
@@ -26,9 +26,9 @@ protected override void Open()
_window.OnNameChange += OnNameSelected;
_window.OnVerbChange += verb => SendMessage(new VoiceMaskChangeVerbMessage(verb));
+ _window.OnVoiceChange += voice => SendMessage(new VoiceMaskChangeVoiceMessage(voice)); // Art-TTS
_window.OnToggle += OnToggle;
_window.OnAccentToggle += OnAccentToggle;
- _window.OnVoiceChange += voice => SendMessage(new VoiceMaskChangeVoiceMessage(voice)); // Art-TTS
}
private void OnNameSelected(string name)
diff --git a/Content.Client/_Art/TTS/TTSSystem.cs b/Content.Client/_Art/TTS/TTSSystem.cs
index 91d93de6e2e..4de7cb05a32 100644
--- a/Content.Client/_Art/TTS/TTSSystem.cs
+++ b/Content.Client/_Art/TTS/TTSSystem.cs
@@ -1,5 +1,5 @@
using Content.Shared.Chat;
-using Content.Shared._Art.ACVar;
+using Content.Shared._Art.ArtCVar;
using Content.Shared._Art.TTS;
using Robust.Client.Audio;
using Robust.Shared.Audio.Components;
diff --git a/Content.Client/_Art/TTS/VoiceMaskNameChangeWindow.xaml.cs b/Content.Client/_Art/TTS/VoiceMaskNameChangeWindow.xaml.cs
index a21e996dd5e..35b9de09f89 100644
--- a/Content.Client/_Art/TTS/VoiceMaskNameChangeWindow.xaml.cs
+++ b/Content.Client/_Art/TTS/VoiceMaskNameChangeWindow.xaml.cs
@@ -1,5 +1,5 @@
using System.Linq;
-using Content.Shared._Art.ACVar;
+using Content.Shared._Art.ArtCVar;
using Robust.Shared.Configuration;
using Robust.Shared.Prototypes;
using Content.Shared._Art.TTS;
@@ -18,8 +18,8 @@ private void ReloadVoices()
{
if (_cfg is null)
return;
- TTSContainer.Visible = _cfg.GetCVar(ArtCVars.TTSEnabled);
- if (!_cfg.GetCVar(ArtCVars.TTSEnabled))
+ TTSContainer.Visible = _cfg.GetCVar(ArtCVars.TTSClientEnabled);
+ if (!_cfg.GetCVar(ArtCVars.TTSClientEnabled))
return;
VoiceSelector.OnItemSelected += args =>
{
diff --git a/Content.Server.Database/Model.cs b/Content.Server.Database/Model.cs
index f54bba7e44c..6861ce22fa3 100644
--- a/Content.Server.Database/Model.cs
+++ b/Content.Server.Database/Model.cs
@@ -331,6 +331,7 @@ public class Profile
public string Sex { get; set; } = null!;
public string Gender { get; set; } = null!;
public string Species { get; set; } = null!;
+ public string Voice { get; set; } = null!; // Art-TTS
[Column(TypeName = "jsonb")] public JsonDocument? OrganMarkings { get; set; } = null!;
[Column(TypeName = "jsonb")] public JsonDocument? Markings { get; set; } = null!;
public string HairName { get; set; } = null!;
diff --git a/Content.Server/Database/ServerDbBase.cs b/Content.Server/Database/ServerDbBase.cs
index 5ed8557c2a4..db0073f46e5 100644
--- a/Content.Server/Database/ServerDbBase.cs
+++ b/Content.Server/Database/ServerDbBase.cs
@@ -209,6 +209,7 @@ private Profile ConvertProfiles(HumanoidCharacterProfile humanoid, int slot, Pro
var appearance = humanoid.Appearance;
var dataNode = _serialization.WriteValue(appearance.Markings, alwaysWrite: true, notNullableOverride: true);
+ profile.Voice = humanoid.Voice; // Art-TTS
profile.CharacterName = humanoid.Name;
profile.FlavorText = humanoid.FlavorText;
profile.Species = humanoid.Species;
diff --git a/Content.Server/Preferences/Managers/ServerPreferencesManager.cs b/Content.Server/Preferences/Managers/ServerPreferencesManager.cs
index 5511375e5f7..b950ef66c78 100644
--- a/Content.Server/Preferences/Managers/ServerPreferencesManager.cs
+++ b/Content.Server/Preferences/Managers/ServerPreferencesManager.cs
@@ -3,7 +3,9 @@
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
+using Content.Server._Art.TTS;
using Content.Server.Database;
+using Content.Shared._Art.TTS;
using Content.Shared.Body;
using Content.Shared.CCVar;
using Content.Shared.Construction.Prototypes;
@@ -113,6 +115,10 @@ internal HumanoidCharacterProfile ConvertProfiles(Profile profile)
if (!_prototypeManager.HasIndex(species))
species = HumanoidCharacterProfile.DefaultSpecies;
+ var voice = profile.Voice;
+ if (voice == String.Empty)
+ voice = TTSConfig.DefaultSexVoice[sex];
+
if (profile.OrganMarkings?.RootElement is { } element)
{
var data = element.ToDataNode();
@@ -182,10 +188,11 @@ internal HumanoidCharacterProfile ConvertProfiles(Profile profile)
),
spawnPriority,
jobs,
- (PreferenceUnavailableMode) profile.PreferenceUnavailable,
+ (PreferenceUnavailableMode)profile.PreferenceUnavailable,
antags.ToHashSet(),
traits.ToHashSet(),
- loadouts
+ loadouts,
+ voice // Art-TTS
);
}
diff --git a/Content.Server/Radio/EntitySystems/HeadsetSystem.cs b/Content.Server/Radio/EntitySystems/HeadsetSystem.cs
index 7d16687d5f2..028613ebca5 100644
--- a/Content.Server/Radio/EntitySystems/HeadsetSystem.cs
+++ b/Content.Server/Radio/EntitySystems/HeadsetSystem.cs
@@ -1,3 +1,4 @@
+using Content.Shared._Art.TTS; // Art-TTS
using Content.Shared.Chat;
using Content.Shared.Inventory.Events;
using Content.Shared.Radio;
@@ -109,7 +110,16 @@ private void OnHeadsetReceive(EntityUid uid, HeadsetComponent component, ref Rad
RaiseLocalEvent(parent, ref relayEvent);
}
+ // Art-TTS Start
if (TryComp(parent, out ActorComponent? actor))
+ {
_netMan.ServerSendMessage(args.ChatMsg, actor.PlayerSession.Channel);
+ if (args.Voice is string voice)
+ {
+ var ev = new TTSRadioPlayEvent(args.Message, voice, GetNetEntity(uid), GetNetEntity(args.MessageSource));
+ RaiseLocalEvent(Transform(uid).ParentUid, ev);
+ }
+ }
+ // Art-TTS End
}
}
diff --git a/Content.Server/Radio/EntitySystems/RadioSystem.cs b/Content.Server/Radio/EntitySystems/RadioSystem.cs
index 740e6b10303..6bd08be60dd 100644
--- a/Content.Server/Radio/EntitySystems/RadioSystem.cs
+++ b/Content.Server/Radio/EntitySystems/RadioSystem.cs
@@ -1,6 +1,7 @@
using Content.Server.Administration.Logs;
using Content.Server.Chat.Systems;
using Content.Server.Power.Components;
+using Content.Shared._Art.TTS; // Art-TTS
using Content.Shared.Chat;
using Content.Shared.Database;
using Content.Shared.Radio;
@@ -53,6 +54,13 @@ private void OnIntrinsicSpeak(EntityUid uid, IntrinsicRadioTransmitterComponent
private void OnIntrinsicReceive(EntityUid uid, IntrinsicRadioReceiverComponent component, ref RadioReceiveEvent args)
{
+ // Art-TTS Start
+ if (args.Voice is not null)
+ {
+ var ev = new TTSRadioPlayEvent(args.Message, args.Voice, GetNetEntity(uid), GetNetEntity(args.MessageSource));
+ RaiseLocalEvent(uid, ev);
+ }
+ // Art-TTS End
if (TryComp(uid, out ActorComponent? actor))
_netMan.ServerSendMessage(args.ChatMsg, actor.PlayerSession.Channel);
}
@@ -92,6 +100,11 @@ public void SendRadioMessage(EntityUid messageSource, string message, RadioChann
? FormattedMessage.EscapeText(message)
: message;
+ // Art-TTS Start
+ string? voice = null;
+ if (TryComp(messageSource, out var tts))
+ voice = tts.VoicePrototypeId;
+ // Art-TTS End
var wrappedMessage = Loc.GetString(speech.Bold ? "chat-radio-message-wrap-bold" : "chat-radio-message-wrap",
("color", channel.Color),
("fontType", speech.FontId),
@@ -109,7 +122,7 @@ public void SendRadioMessage(EntityUid messageSource, string message, RadioChann
NetEntity.Invalid,
null);
var chatMsg = new MsgChatMessage { Message = chat };
- var ev = new RadioReceiveEvent(message, messageSource, channel, radioSource, chatMsg);
+ var ev = new RadioReceiveEvent(message, messageSource, channel, radioSource, chatMsg, voice); // voice // Art-TTS
var sendAttemptEv = new RadioSendAttemptEvent(channel, radioSource);
RaiseLocalEvent(ref sendAttemptEv);
diff --git a/Content.Server/Radio/RadioEvent.cs b/Content.Server/Radio/RadioEvent.cs
index 49ff63f824e..6d019b2c2d7 100644
--- a/Content.Server/Radio/RadioEvent.cs
+++ b/Content.Server/Radio/RadioEvent.cs
@@ -4,7 +4,7 @@
namespace Content.Server.Radio;
[ByRefEvent]
-public readonly record struct RadioReceiveEvent(string Message, EntityUid MessageSource, RadioChannelPrototype Channel, EntityUid RadioSource, MsgChatMessage ChatMsg);
+public readonly record struct RadioReceiveEvent(string Message, EntityUid MessageSource, RadioChannelPrototype Channel, EntityUid RadioSource, MsgChatMessage ChatMsg, string? Voice = null); // string? Voice = null /// Art-TTS
///
/// Event raised on the parent entity of a headset radio when a radio message is received
diff --git a/Content.Server/VoiceMask/VoiceMaskSystem.cs b/Content.Server/VoiceMask/VoiceMaskSystem.cs
index 72072244402..060a33de979 100644
--- a/Content.Server/VoiceMask/VoiceMaskSystem.cs
+++ b/Content.Server/VoiceMask/VoiceMaskSystem.cs
@@ -192,11 +192,11 @@ private void OpenUI(VoiceMaskSetNameEvent ev)
private void UpdateUI(Entity entity)
{
if (_uiSystem.HasUi(entity, VoiceMaskUIKey.Key))
- _uiSystem.SetUiState(entity.Owner, VoiceMaskUIKey.Key, new VoiceMaskBuiState(GetCurrentVoiceName(entity), entity.Comp.VoiceId, entity.Comp.VoiceMaskSpeechVerb, entity.Comp.Active, entity.Comp.AccentHide)); // Art-TTS
+ _uiSystem.SetUiState(entity.Owner, VoiceMaskUIKey.Key, new VoiceMaskBuiState(GetCurrentVoiceName(entity), entity.Comp.VoiceId, entity.Comp.VoiceMaskSpeechVerb, entity.Comp.Active, entity.Comp.AccentHide )); // Art-TTS
}
#endregion
- #region Helper functions
+ #region Helper functions
private string GetCurrentVoiceName(Entity entity)
{
return entity.Comp.VoiceMaskName ?? Loc.GetString("voice-mask-default-name-override");
diff --git a/Content.Server/_Art/TTS/TTSManager.cs b/Content.Server/_Art/TTS/TTSManager.cs
new file mode 100644
index 00000000000..697f1f33b9c
--- /dev/null
+++ b/Content.Server/_Art/TTS/TTSManager.cs
@@ -0,0 +1,214 @@
+using System.Linq;
+using System.Net;
+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._Art.ArtCVar;
+using Prometheus;
+using Robust.Shared.Configuration;
+
+namespace Content.Server._Art.TTS;
+
+// ReSharper disable once InconsistentNaming
+public sealed class TTSManager
+{
+ private static readonly Histogram RequestTimings = Metrics.CreateHistogram(
+ "tts_req_timings",
+ "Timings of TTS API requests",
+ new HistogramConfiguration()
+ {
+ 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 ReusedCount = Metrics.CreateCounter(
+ "tts_reused_count",
+ "Amount of reused TTS audio from cache.");
+
+ [Dependency] private readonly IConfigurationManager _cfg = default!;
+
+ private readonly HttpClient _httpClient = new();
+
+ private ISawmill _sawmill = default!;
+ private readonly Dictionary _cache = new();
+ private readonly Dictionary _semaphores = new();
+ private readonly List _cacheKeysSeq = new();
+ private int _maxCachedCount = 200;
+ private string _apiUrl = string.Empty;
+ private string _apiToken = string.Empty;
+
+ public void Initialize()
+ {
+ _sawmill = Logger.GetSawmill("tts");
+ _cfg.OnValueChanged(ArtCVars.TTSMaxCache, val =>
+ {
+ _maxCachedCount = val;
+ ResetCache();
+ }, true);
+ _cfg.OnValueChanged(ArtCVars.TTSApiUrl, v => _apiUrl = v, true);
+ _cfg.OnValueChanged(ArtCVars.TTSApiToken, v =>
+ {
+ _httpClient.DefaultRequestHeaders.Authorization =
+ new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", v);
+ _apiToken = v;
+ }, true);
+ }
+
+ ///
+ /// Generates audio with passed text by API
+ ///
+ /// Identifier of speaker
+ /// SSML formatted text
+ /// Wav audio bytes or null if failed
+ public async Task ConvertTextToSpeech(string speaker, string text, string? effect)
+ {
+ WantedCount.Inc();
+ var cacheKey = GenerateCacheKey(speaker, text, effect);
+ _sawmill.Verbose($"Cache key for '{text}' is '{cacheKey}'");
+ var semaphore = _semaphores.GetValueOrDefault(cacheKey, new SemaphoreSlim(1, 1));
+ _semaphores[cacheKey] = semaphore;
+ try
+ {
+ await semaphore.WaitAsync();
+ if (_cache.TryGetValue(cacheKey, out var data))
+ {
+ ReusedCount.Inc();
+ _sawmill.Verbose($"Use cached sound for '{text}' speech by '{speaker}'({effect}) speaker");
+ return data;
+ }
+
+ _sawmill.Verbose($"Generate new audio for '{text}' speech by '{speaker}'({effect}) speaker");
+
+ var reqTime = DateTime.UtcNow;
+ try
+ {
+ var timeout = _cfg.GetCVar(ArtCVars.TTSApiTimeout);
+ var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeout));
+ // c4llv07e fix tts start
+ if (effect == null)
+ effect = "";
+ var requestUrl = _apiUrl + $"?speaker={speaker}&text={text}&ext=wav&effect={effect}";
+ var response = await _httpClient.GetAsync(requestUrl, cts.Token);
+ _sawmill.Debug($"requested api url: {requestUrl}");
+ // c4llv07e fix tts end
+ if (!response.IsSuccessStatusCode)
+ {
+ if (response.StatusCode == HttpStatusCode.TooManyRequests)
+ {
+ _sawmill.Warning($"TTS request for {text} was rate limited");
+ return null;
+ }
+
+ _sawmill.Error($"TTS request returned bad status code: {response.StatusCode}");
+ return null;
+ }
+
+ var soundData = await response.Content.ReadAsByteArrayAsync();
+
+ // Because internet is slow and mutexes is hard in sandbox, we might override previous cache, but it doesn't
+ // really matter.
+ _cache[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}'({effect}) speaker ({soundData.Length} bytes)");
+ RequestTimings.WithLabels("Success").Observe((DateTime.UtcNow - reqTime).TotalSeconds);
+
+ return soundData;
+ }
+ catch (TaskCanceledException)
+ {
+ RequestTimings.WithLabels("Timeout").Observe((DateTime.UtcNow - reqTime).TotalSeconds);
+ _sawmill.Error($"Timeout of request generation new audio for '{text}' speech by '{speaker}'({effect}) speaker");
+ return null;
+ }
+ catch (Exception e)
+ {
+ RequestTimings.WithLabels("Error").Observe((DateTime.UtcNow - reqTime).TotalSeconds);
+ _sawmill.Error($"Failed of request generation new sound for '{text}' speech by '{speaker}'({effect}) speaker\n{e}");
+ return null;
+ }
+ }
+ finally
+ {
+ _semaphores.Remove(cacheKey);
+ semaphore.Release();
+ }
+ }
+
+ public void ResetCache()
+ {
+ _cache.Clear();
+ _cacheKeysSeq.Clear();
+ }
+
+ private string GenerateCacheKey(string speaker, string text, string? effect)
+ {
+ var key = $"{speaker}[{effect}]/{text}";
+ byte[] keyData = Encoding.UTF8.GetBytes(key);
+ var sha256 = System.Security.Cryptography.SHA256.Create();
+ var bytes = sha256.ComputeHash(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"; // wav is too big
+ }
+
+ 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/_Art/TTS/TTSSystem.RateLimit.cs b/Content.Server/_Art/TTS/TTSSystem.RateLimit.cs
new file mode 100644
index 00000000000..7c248aabe3b
--- /dev/null
+++ b/Content.Server/_Art/TTS/TTSSystem.RateLimit.cs
@@ -0,0 +1,35 @@
+using Content.Shared._Art.ArtCVar;
+using Content.Server.Chat.Managers;
+using Content.Server.Players.RateLimiting;
+using Content.Shared.Players.RateLimiting;
+using Robust.Shared.Player;
+
+namespace Content.Server._Art.TTS;
+
+public sealed partial class TTSSystem
+{
+ [Dependency] private readonly PlayerRateLimitManager _rateLimitManager = default!;
+ [Dependency] private readonly IChatManager _chat = default!;
+
+ private const string RateLimitKey = "TTS";
+
+ private void RegisterRateLimits()
+ {
+ _rateLimitManager.Register(RateLimitKey,
+ new RateLimitRegistration(
+ ArtCVars.TTSRateLimitPeriod,
+ ArtCVars.TTSRateLimitCount,
+ RateLimitPlayerLimited)
+ );
+ }
+
+ private void RateLimitPlayerLimited(ICommonSession player)
+ {
+ _chat.DispatchServerMessage(player, Loc.GetString("tts-rate-limited"), suppressLog: true);
+ }
+
+ private RateLimitStatus HandleRateLimit(ICommonSession player)
+ {
+ return _rateLimitManager.CountAction(player, RateLimitKey);
+ }
+}
diff --git a/Content.Server/_Art/TTS/TTSSystem.SSML.cs b/Content.Server/_Art/TTS/TTSSystem.SSML.cs
new file mode 100644
index 00000000000..c079e0cb8b2
--- /dev/null
+++ b/Content.Server/_Art/TTS/TTSSystem.SSML.cs
@@ -0,0 +1,23 @@
+namespace Content.Server._Art.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/_Art/TTS/TTSSystem.Sanitize.cs b/Content.Server/_Art/TTS/TTSSystem.Sanitize.cs
new file mode 100644
index 00000000000..3f34b6fd9c6
--- /dev/null
+++ b/Content.Server/_Art/TTS/TTSSystem.Sanitize.cs
@@ -0,0 +1,340 @@
+using System.Text;
+using System.Text.RegularExpressions;
+using Content.Shared.Chat;
+
+namespace Content.Server._Art.TTS;
+
+// ReSharper disable once InconsistentNaming
+public sealed partial class TTSSystem
+{
+ private void OnTransformSpeech(TransformSpeechEvent args)
+ {
+ if (!_isEnabled) return;
+ args.Message = args.Message.Replace("+", "");
+ }
+
+ private static readonly Regex NonAllowedChars = new Regex(@"[^a-zA-Zа-яА-ЯёЁ0-9,\-+?!. ]", RegexOptions.Compiled);
+ private static readonly Regex Dygraphs = new Regex(@"(jsh|ch|sh|ja|ju|je|zh|hh|ih|jh|eh)", RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.IgnoreCase);
+ private static readonly Regex Latin = new Regex(@"[a-zA-Z]", RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.IgnoreCase);
+ private static readonly Regex MatchedWord = new Regex(@"(? 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", "кс"},
+ {"w", "в"},
+ {"ch", "ч"},
+ {"sh", "ш"},
+ {"ph", "ф"},
+ {"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[] Tens =
+ {
+ "", "десять", "двадцать", "тридцать", "сорок", "пятьдесят",
+ "шестьдесят", "семьдесят", "восемьдесят", "девяносто"
+ };
+
+ public static string NumberToText(long value, bool male = true)
+ {
+ if (value >= (long)Math.Pow(10, 15))
+ return String.Empty;
+
+ if (value == 0)
+ return "ноль";
+
+ var str = new StringBuilder();
+
+ 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 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)
+ {
+ 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/_Art/TTS/TTSSystem.cs b/Content.Server/_Art/TTS/TTSSystem.cs
new file mode 100644
index 00000000000..3339eb2bcdb
--- /dev/null
+++ b/Content.Server/_Art/TTS/TTSSystem.cs
@@ -0,0 +1,169 @@
+using Content.Shared.Chat;
+using Content.Server.Players.RateLimiting;
+using Content.Shared.GameTicking;
+using Content.Shared.Players.RateLimiting;
+using Content.Shared._Art.ArtCVar;
+using Content.Shared._Art.TTS;
+using Robust.Shared.Configuration;
+using Robust.Shared.Player;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+using System.Threading.Tasks;
+
+namespace Content.Server._Art.TTS;
+
+// ReSharper disable once InconsistentNaming
+public sealed partial class TTSSystem : EntitySystem
+{
+ [Dependency] private readonly IConfigurationManager _cfg = default!;
+ [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
+ [Dependency] private readonly TTSManager _ttsManager = default!;
+ [Dependency] private readonly SharedTransformSystem _xforms = default!;
+ [Dependency] private readonly IRobustRandom _rng = default!;
+
+ private List _ignoredRecipients = new();
+
+ private readonly List _sampleText =
+ new()
+ {
+ "Съешь же ещё этих мягких французских булок, да выпей чаю.",
+ "Клоун, прекрати разбрасывать банановые кожурки офицерам под ноги!",
+ "Капитан, вы уверены что хотите назначить клоуна на должность главы персонала?",
+ "Эс Бэ! Тут человек в сером костюме, с тулбоксом и в маске! Помогите!!",
+ "Учёные, тут странная аномалия в баре! Она уже съела мима!",
+ "Я надеюсь что инженеры внимательно следят за сингулярностью...",
+ "Вы слышали эти странные крики в техах? Мне кажется туда ходить небезопасно.",
+ "Вы не видели Гамлета? Мне кажется он забегал к вам на кухню.",
+ "Здесь есть доктор? Человек умирает от отравленного пончика! Нужна помощь!",
+ "Вам нужно согласие и печать квартирмейстера, если вы хотите сделать заказ на партию дробовиков.",
+ "Возле эвакуационного шаттла разгерметизация! Инженеры, нам срочно нужна ваша помощь!",
+ "Бармен, налей мне самого крепкого вина, которое есть в твоих запасах!"
+ };
+
+ private const int MaxMessageChars = 100 * 2; // same as SingleBubbleCharLimit * 2
+ private bool _isEnabled = false;
+
+ public override void Initialize()
+ {
+ _cfg.OnValueChanged(ArtCVars.TTSClientEnabled, v => _isEnabled = v, true);
+
+ SubscribeLocalEvent(OnTransformSpeech);
+ SubscribeLocalEvent(OnEntitySpoke);
+ SubscribeLocalEvent(OnRoundRestartCleanup);
+ SubscribeLocalEvent(OnTTSRadioPlayEvent);
+
+ SubscribeNetworkEvent(OnRequestPreviewTTS);
+ SubscribeNetworkEvent(OnClientOptionTTS);
+
+ RegisterRateLimits();
+ }
+
+ private void OnRoundRestartCleanup(RoundRestartCleanupEvent ev)
+ {
+ _ttsManager.ResetCache();
+ }
+
+ private async void OnClientOptionTTS(ClientOptionTTSEvent ev, EntitySessionEventArgs args)
+ {
+ if (ev.Enabled)
+ _ignoredRecipients.Remove(args.SenderSession);
+ else
+ _ignoredRecipients.Add(args.SenderSession);
+ }
+
+ private async void OnRequestPreviewTTS(RequestPreviewTTSEvent ev, EntitySessionEventArgs args)
+ {
+ if (!_isEnabled ||
+ !_prototypeManager.TryIndex(ev.VoiceId, out var protoVoice))
+ return;
+
+ if (HandleRateLimit(args.SenderSession) != RateLimitStatus.Allowed)
+ return;
+
+ var previewText = _rng.Pick(_sampleText);
+ var soundData = await GenerateTTS(previewText, protoVoice.Speaker);
+ if (soundData is null)
+ return;
+
+ RaiseNetworkEvent(new PlayTTSEvent(soundData, null), Filter.SinglePlayer(args.SenderSession));
+ }
+
+ private async void OnEntitySpoke(EntityUid uid, TTSComponent component, EntitySpokeEvent args)
+ {
+ var voiceId = component.VoicePrototypeId;
+ if (!_isEnabled ||
+ args.Message.Length > MaxMessageChars ||
+ voiceId == null)
+ return;
+
+ var voiceEv = new TransformSpeakerVoiceEvent(uid, voiceId);
+ RaiseLocalEvent(uid, voiceEv);
+ voiceId = voiceEv.VoiceId;
+
+ if (!_prototypeManager.TryIndex(voiceId, out var protoVoice))
+ return;
+
+ if (args.ObfuscatedMessage != null)
+ {
+ HandleWhisper(uid, args.Message, args.ObfuscatedMessage, protoVoice.Speaker);
+ return;
+ }
+
+ HandleSay(uid, args.Message, protoVoice.Speaker);
+ }
+
+ private async void OnTTSRadioPlayEvent(EntityUid uid, ActorComponent comp, TTSRadioPlayEvent args)
+ {
+ var soundData = await GenerateTTS(args.Message, args.Voice, "radio");
+ if (soundData is null) return;
+ RaiseNetworkEvent(new PlayTTSEvent(soundData, args.Source, false, args.Author), uid);
+ }
+
+ private async void HandleSay(EntityUid uid, string message, string speaker)
+ {
+ var soundData = await GenerateTTS(message, speaker);
+ if (soundData is null) return;
+ RaiseNetworkEvent(new PlayTTSEvent(soundData, GetNetEntity(uid)), Filter.Pvs(uid).RemovePlayers(_ignoredRecipients));
+ }
+
+ private async void HandleWhisper(EntityUid uid, string message, string obfMessage, string speaker)
+ {
+ var fullSoundData = await GenerateTTS(message, speaker);
+ if (fullSoundData is null) return;
+
+ var obfSoundData = await GenerateTTS(obfMessage, speaker);
+ if (obfSoundData is null) return;
+
+ var fullTtsEvent = new PlayTTSEvent(fullSoundData, GetNetEntity(uid), true);
+ var obfTtsEvent = new PlayTTSEvent(obfSoundData, GetNetEntity(uid), true);
+
+ // TODO: Check obstacles
+ var xformQuery = GetEntityQuery();
+ var sourcePos = _xforms.GetWorldPosition(xformQuery.GetComponent(uid), xformQuery);
+ var receptions = Filter.Pvs(uid).Recipients;
+ foreach (var session in receptions)
+ {
+ if (!session.AttachedEntity.HasValue) continue;
+
+ if (_ignoredRecipients.Contains(session)) continue;
+
+ var xform = xformQuery.GetComponent(session.AttachedEntity.Value);
+ var distance = (sourcePos - _xforms.GetWorldPosition(xform, xformQuery)).Length();
+ if (distance > SharedChatSystem.VoiceRange * SharedChatSystem.VoiceRange)
+ continue;
+
+ RaiseNetworkEvent(distance > SharedChatSystem.WhisperClearRange ? obfTtsEvent : fullTtsEvent, session);
+ }
+ }
+
+ // ReSharper disable once InconsistentNaming
+ private async Task GenerateTTS(string text, string speaker, string? effect = null)
+ {
+ var textSanitized = Sanitize(text);
+ if (textSanitized == "") return null;
+ if (char.IsLetter(textSanitized[^1]))
+ textSanitized += ".";
+
+ return await _ttsManager.ConvertTextToSpeech(speaker, textSanitized, effect); // c4llv07e fix tts
+ }
+}
diff --git a/Content.Server/_Art/TTS/VoiceMaskSystem.TTS.cs b/Content.Server/_Art/TTS/VoiceMaskSystem.TTS.cs
new file mode 100644
index 00000000000..462b0909e57
--- /dev/null
+++ b/Content.Server/_Art/TTS/VoiceMaskSystem.TTS.cs
@@ -0,0 +1,32 @@
+using Content.Server._Art.TTS;
+using Content.Shared.VoiceMask;
+using Content.Shared._Art.TTS;
+using Content.Shared.Inventory;
+
+namespace Content.Server.VoiceMask;
+
+public partial class VoiceMaskSystem
+{
+ private void InitializeTTS()
+ {
+ SubscribeLocalEvent>(OnSpeakerVoiceTransform);
+ SubscribeLocalEvent(OnChangeVoice);
+ }
+
+ private void OnSpeakerVoiceTransform(Entity ent, ref InventoryRelayedEvent args)
+ {
+ args.Args.VoiceId = ent.Comp.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/Humanoid/HumanoidProfileComponent.cs b/Content.Shared/Humanoid/HumanoidProfileComponent.cs
index d050e2494da..9af7e7d4403 100644
--- a/Content.Shared/Humanoid/HumanoidProfileComponent.cs
+++ b/Content.Shared/Humanoid/HumanoidProfileComponent.cs
@@ -1,3 +1,4 @@
+using Content.Shared._Art.TTS; // Art-TTS
using Content.Shared.Humanoid.Prototypes;
using Content.Shared.Preferences;
using Robust.Shared.Enums;
@@ -24,4 +25,9 @@ public sealed partial class HumanoidProfileComponent : Component
[DataField, AutoNetworkedField]
public ProtoId Species = HumanoidCharacterProfile.DefaultSpecies;
+
+ // Art-TTS Start
+ [DataField("voice")]
+ public ProtoId Voice = TTSConfig.DefaultVoice; // HumanoidCharacterProfile
+ // Art-TTS End
}
diff --git a/Content.Shared/Humanoid/HumanoidProfileExportV1.cs b/Content.Shared/Humanoid/HumanoidProfileExportV1.cs
index 59b48ab5e84..2ee652c6a66 100644
--- a/Content.Shared/Humanoid/HumanoidProfileExportV1.cs
+++ b/Content.Shared/Humanoid/HumanoidProfileExportV1.cs
@@ -1,6 +1,7 @@
using System.Numerics;
using Content.Shared.Humanoid.Markings;
using Content.Shared.Humanoid.Prototypes;
+using Content.Shared._Art.TTS; // Art-TTS
using Content.Shared.Preferences;
using Content.Shared.Preferences.Loadouts;
using Content.Shared.Roles;
@@ -61,6 +62,11 @@ public sealed partial class HumanoidCharacterProfileV1
[DataField]
public ProtoId Species;
+ // Art-TTS Start
+ [DataField]
+ public ProtoId Voice;
+ // Art-TTS End
+
[DataField]
public int Age;
@@ -70,6 +76,7 @@ public sealed partial class HumanoidCharacterProfileV1
[DataField]
public Gender Gender;
+
[DataField]
public HumanoidCharacterAppearanceV1 Appearance;
@@ -81,7 +88,7 @@ public sealed partial class HumanoidCharacterProfileV1
public HumanoidCharacterProfile ToV2()
{
- return new(Name, FlavorText, Species, Age, Sex, Gender, Appearance.ToV2(Species), SpawnPriority, JobPriorities, PreferenceUnavailable, AntagPreferences, TraitPreferences, Loadouts);
+ return new(Name, FlavorText, Species, Age, Sex, Gender, Appearance.ToV2(Species), SpawnPriority, JobPriorities, PreferenceUnavailable, AntagPreferences, TraitPreferences, Loadouts, Voice); // Voice /// Art-TTS
}
}
diff --git a/Content.Shared/Inventory/InventorySystem.Relay.cs b/Content.Shared/Inventory/InventorySystem.Relay.cs
index 8101c19eab5..c1ab254b4c1 100644
--- a/Content.Shared/Inventory/InventorySystem.Relay.cs
+++ b/Content.Shared/Inventory/InventorySystem.Relay.cs
@@ -1,3 +1,4 @@
+using Content.Shared._Art.TTS; // Art-TTS
using Content.Shared.Armor;
using Content.Shared.Atmos;
using Content.Shared.Chat;
@@ -63,6 +64,8 @@ public void InitializeRelay()
SubscribeLocalEvent(RelayInventoryEvent);
SubscribeLocalEvent(RelayInventoryEvent);
+ SubscribeLocalEvent(RelayInventoryEvent); // Art-TTS
+
// by-ref events
SubscribeLocalEvent(RefRelayInventoryEvent);
SubscribeLocalEvent(RefRelayInventoryEvent);
diff --git a/Content.Shared/Preferences/HumanoidCharacterProfile.cs b/Content.Shared/Preferences/HumanoidCharacterProfile.cs
index 9791350520e..553dfcd0d07 100644
--- a/Content.Shared/Preferences/HumanoidCharacterProfile.cs
+++ b/Content.Shared/Preferences/HumanoidCharacterProfile.cs
@@ -1,6 +1,7 @@
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
+using Content.Shared._Art.TTS; // Art-TTS
using Content.Shared.CCVar;
using Content.Shared.GameTicking;
using Content.Shared.Humanoid;
@@ -34,6 +35,11 @@ public sealed partial class HumanoidCharacterProfile
private static readonly Regex RestrictedNameRegex = new(@"[^А-ЯЁа-яёA-Za-z0-9 '\-]"); // OpenSpace-Edit
private static readonly Regex ICNameCaseRegex = new(@"^(?\w)|\b(?\w)(?=\w*$)");
+ // Art-TTS Start
+ [DataField]
+ public string Voice { get; set; } = TTSConfig.DefaultVoice;
+ // Art-TTS End
+
///
/// Job preferences for initial spawn.
///
@@ -136,7 +142,8 @@ public HumanoidCharacterProfile(
PreferenceUnavailableMode preferenceUnavailable,
HashSet> antagPreferences,
HashSet> traitPreferences,
- Dictionary loadouts)
+ Dictionary loadouts,
+ string voice) // Art-TTS
{
Name = name;
FlavorText = flavortext;
@@ -151,6 +158,7 @@ public HumanoidCharacterProfile(
_antagPreferences = antagPreferences;
_traitPreferences = traitPreferences;
_loadouts = loadouts;
+ Voice = voice; // Art-TTS
var hasHighPrority = false;
foreach (var (key, value) in _jobPriorities)
@@ -181,7 +189,8 @@ public HumanoidCharacterProfile(HumanoidCharacterProfile other)
other.PreferenceUnavailable,
new HashSet>(other.AntagPreferences),
new HashSet>(other.TraitPreferences),
- new Dictionary(other.Loadouts))
+ new Dictionary(other.Loadouts),
+ other.Voice) // Art-TTS
{
}
@@ -243,6 +252,15 @@ public static HumanoidCharacterProfile RandomWithSpecies(string? species = null)
age = random.Next(speciesPrototype.MinAge, speciesPrototype.OldAge); // people don't look and keep making 119 year old characters with zero rp, cap it at middle aged
}
+ // Art-TTS Start
+ var voices = prototypeManager.EnumeratePrototypes().ToArray();
+ string voiceId = string.Empty;
+ if (voices.Count() != 0)
+ {
+ voiceId = random.Pick(voices).ID;
+ }
+ // Art-TTS End
+
var gender = Gender.Epicene;
switch (sex)
@@ -265,6 +283,7 @@ public static HumanoidCharacterProfile RandomWithSpecies(string? species = null)
Gender = gender,
Species = species,
Appearance = HumanoidCharacterAppearance.Random(species, sex),
+ Voice = voiceId, // Art-TTS
};
}
@@ -298,6 +317,13 @@ public HumanoidCharacterProfile WithSpecies(string species)
return new(this) { Species = species };
}
+ // Art-TTS Start
+ public HumanoidCharacterProfile WithVoice(string voice)
+ {
+ return new(this) { Voice = voice };
+ }
+ // Art-TTS End
+
public HumanoidCharacterProfile WithCharacterAppearance(HumanoidCharacterAppearance appearance)
{
@@ -624,6 +650,12 @@ public void EnsureValid(ICommonSession session, IDependencyCollection collection
_traitPreferences.Clear();
_traitPreferences.UnionWith(GetValidTraits(traits, prototypeManager));
+ // Art-TTS Start
+ prototypeManager.TryIndex(Voice, out var voice);
+ if (voice is null)
+ Voice = TTSConfig.DefaultSexVoice[sex];
+ // Art-TTS End
+
// Checks prototypes exist for all loadouts and dump / set to default if not.
var toRemove = new ValueList();
diff --git a/Content.Shared/VoiceMask/SharedVoiceMaskSystem.cs b/Content.Shared/VoiceMask/SharedVoiceMaskSystem.cs
index 9d586a5af8c..c8c29b3184c 100644
--- a/Content.Shared/VoiceMask/SharedVoiceMaskSystem.cs
+++ b/Content.Shared/VoiceMask/SharedVoiceMaskSystem.cs
@@ -13,12 +13,14 @@ public sealed class VoiceMaskBuiState : BoundUserInterfaceState
{
public readonly string Name;
public readonly string? Verb;
+ public readonly string Voice; // Art-TTS
public readonly bool Active;
public readonly bool AccentHide;
- public VoiceMaskBuiState(string name, string? verb, bool active, bool accentHide)
+ public VoiceMaskBuiState(string name, string voice, string? verb, bool active, bool accentHide) // Art-TTS
{
Name = name;
+ Voice = voice; // Art-TTS
Verb = verb;
Active = active;
AccentHide = accentHide;
diff --git a/Content.Shared/VoiceMask/VoiceMaskComponent.cs b/Content.Shared/VoiceMask/VoiceMaskComponent.cs
index b53a6150947..704f6e98e81 100644
--- a/Content.Shared/VoiceMask/VoiceMaskComponent.cs
+++ b/Content.Shared/VoiceMask/VoiceMaskComponent.cs
@@ -1,5 +1,6 @@
using Content.Shared.Speech;
using Robust.Shared.Prototypes;
+using Content.Shared._Art.TTS; // Art-TTS
namespace Content.Shared.VoiceMask;
@@ -14,6 +15,12 @@ namespace Content.Shared.VoiceMask;
[RegisterComponent]
public sealed partial class VoiceMaskComponent : Component
{
+ // Art-TTS Start
+ [DataField]
+ [ViewVariables(VVAccess.ReadWrite)]
+ public string VoiceId = TTSConfig.DefaultVoice;
+ // Art-TTS End
+
///
/// The name that will override an entities default name. If null, it will use the default override.
///
diff --git a/Content.Shared/_Art/CVars/ArtCVars.cs b/Content.Shared/_Art/CVars/ArtCVars.cs
new file mode 100644
index 00000000000..24ed3e75afb
--- /dev/null
+++ b/Content.Shared/_Art/CVars/ArtCVars.cs
@@ -0,0 +1,85 @@
+using Robust.Shared;
+using Robust.Shared.Configuration;
+
+namespace Content.Shared._Art.ArtCVar;
+
+///
+/// _OpenSpace modules console variables
+///
+[CVarDefs]
+// ReSharper disable once InconsistentNaming
+public sealed class ArtCVars : CVars
+{
+ /**
+ * TTS (Text-To-Speech)
+ */
+
+ ///
+ /// URL of the TTS server API.
+ ///
+ public static readonly CVarDef TTSClientEnabled =
+ CVarDef.Create("tts.enabled", false, CVar.SERVER | CVar.REPLICATED | CVar.ARCHIVE);
+
+ ///
+ /// URL of the TTS server API.
+ ///
+ public static readonly CVarDef TTSApiUrl =
+ CVarDef.Create("tts.api_url", "", CVar.SERVERONLY | CVar.ARCHIVE);
+
+ ///
+ /// Auth token of the TTS server API.
+ ///
+ public static readonly CVarDef TTSApiToken =
+ CVarDef.Create("tts.api_token", "", CVar.SERVERONLY | CVar.CONFIDENTIAL);
+
+ ///
+ /// Amount of seconds before timeout for API
+ ///
+ public static readonly CVarDef TTSApiTimeout =
+ CVarDef.Create("tts.api_timeout", 5, CVar.SERVERONLY | CVar.ARCHIVE);
+
+ ///
+ /// 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.
+ ///
+ public static readonly CVarDef TTSMaxCache =
+ CVarDef.Create("tts.max_cache", 250, CVar.SERVERONLY | CVar.ARCHIVE);
+
+ ///
+ /// Tts rate limit values are accounted in periods of this size (seconds).
+ /// After the period has passed, the count resets.
+ ///
+ public static readonly CVarDef TTSRateLimitPeriod =
+ CVarDef.Create("tts.rate_limit_period", 2f, CVar.SERVERONLY);
+
+ ///
+ /// How many tts preview messages are allowed in a single rate limit period.
+ ///
+ public static readonly CVarDef TTSRateLimitCount =
+ CVarDef.Create("tts.rate_limit_count", 3, CVar.SERVERONLY);
+
+ /*
+ * Peaceful Round End
+ */
+
+ ///
+ /// Making everyone a pacifist at the end of a round.
+ ///
+ // public static readonly CVarDef PeacefulRoundEnd =
+ // CVarDef.Create("game.peaceful_end", false, CVar.SERVERONLY);
+
+ /*
+ * Station Goal
+ */
+
+ ///
+ /// Send station goal on round start or not.
+ ///
+ // public static readonly CVarDef StationGoal =
+ // CVarDef.Create("game.station_goal", true, CVar.SERVERONLY);
+}
diff --git a/Content.Shared/_Art/TTS/ClientOptionTTSEvent.cs b/Content.Shared/_Art/TTS/ClientOptionTTSEvent.cs
new file mode 100644
index 00000000000..632bf7713c3
--- /dev/null
+++ b/Content.Shared/_Art/TTS/ClientOptionTTSEvent.cs
@@ -0,0 +1,13 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared._Art.TTS;
+
+[Serializable, NetSerializable]
+public sealed class ClientOptionTTSEvent : EntityEventArgs
+{
+ public bool Enabled { get; }
+ public ClientOptionTTSEvent(bool enabled)
+ {
+ Enabled = enabled;
+ }
+}
diff --git a/Content.Shared/_Art/TTS/PlayTTSEvent.cs b/Content.Shared/_Art/TTS/PlayTTSEvent.cs
new file mode 100644
index 00000000000..b246344bcfe
--- /dev/null
+++ b/Content.Shared/_Art/TTS/PlayTTSEvent.cs
@@ -0,0 +1,28 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared._Art.TTS;
+
+[Serializable, NetSerializable]
+// ReSharper disable once InconsistentNaming
+public sealed class PlayTTSEvent : EntityEventArgs
+{
+ public byte[] Data { get; }
+ ///
+ /// Source of the sound.
+ ///
+ public NetEntity? SourceUid { get; }
+ ///
+ /// Author of the sound.
+ /// Used for audio queue.
+ ///
+ public NetEntity? Author { get; }
+ public bool IsWhisper { get; }
+
+ public PlayTTSEvent(byte[] data, NetEntity? sourceUid = null, bool isWhisper = false, NetEntity? author = null)
+ {
+ Data = data;
+ SourceUid = sourceUid;
+ IsWhisper = isWhisper;
+ Author = author ?? sourceUid;
+ }
+}
diff --git a/Content.Shared/_Art/TTS/RequestPreviewTTSEvent.cs b/Content.Shared/_Art/TTS/RequestPreviewTTSEvent.cs
new file mode 100644
index 00000000000..8771cf5fc6a
--- /dev/null
+++ b/Content.Shared/_Art/TTS/RequestPreviewTTSEvent.cs
@@ -0,0 +1,10 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared._Art.TTS;
+
+// ReSharper disable once InconsistentNaming
+[Serializable, NetSerializable]
+public sealed class RequestPreviewTTSEvent(string voiceId) : EntityEventArgs
+{
+ public string VoiceId { get; } = voiceId;
+}
diff --git a/Content.Shared/_Art/TTS/SharedVoiceMaskSystem.cs b/Content.Shared/_Art/TTS/SharedVoiceMaskSystem.cs
new file mode 100644
index 00000000000..fbcd2eaefc6
--- /dev/null
+++ b/Content.Shared/_Art/TTS/SharedVoiceMaskSystem.cs
@@ -0,0 +1,14 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.VoiceMask;
+
+[Serializable, NetSerializable]
+public sealed class VoiceMaskChangeVoiceMessage : BoundUserInterfaceMessage
+{
+ public readonly string Voice;
+
+ public VoiceMaskChangeVoiceMessage(string voice)
+ {
+ Voice = voice;
+ }
+}
diff --git a/Content.Shared/_Art/TTS/TTSComponent.cs b/Content.Shared/_Art/TTS/TTSComponent.cs
new file mode 100644
index 00000000000..dc736f38e08
--- /dev/null
+++ b/Content.Shared/_Art/TTS/TTSComponent.cs
@@ -0,0 +1,33 @@
+using Content.Shared.Inventory;
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
+
+namespace Content.Shared._Art.TTS;
+
+///
+/// Apply TTS for entity chat say messages
+///
+[RegisterComponent, NetworkedComponent]
+// ReSharper disable once InconsistentNaming
+public sealed partial class TTSComponent : Component
+{
+ ///
+ /// Prototype of used voice for TTS.
+ ///
+ [ViewVariables(VVAccess.ReadWrite)]
+ [DataField("voice", customTypeSerializer: typeof(PrototypeIdSerializer))]
+ public string? VoicePrototypeId { get; set; }
+}
+
+public sealed class TransformSpeakerVoiceEvent : EntityEventArgs, IInventoryRelayEvent
+{
+ public SlotFlags TargetSlots { get; } = SlotFlags.MASK;
+ public EntityUid Sender;
+ public string VoiceId;
+
+ public TransformSpeakerVoiceEvent(EntityUid sender, string voiceId)
+ {
+ Sender = sender;
+ VoiceId = voiceId;
+ }
+}
diff --git a/Content.Shared/_Art/TTS/TTSConfig.cs b/Content.Shared/_Art/TTS/TTSConfig.cs
new file mode 100644
index 00000000000..6efdb1e645f
--- /dev/null
+++ b/Content.Shared/_Art/TTS/TTSConfig.cs
@@ -0,0 +1,17 @@
+using Content.Shared.Humanoid;
+
+namespace Content.Shared._Art.TTS;
+
+public sealed class TTSConfig
+{
+ public const string DefaultVoice = "gman";
+ public static readonly Dictionary DefaultSexVoice = new()
+ {
+ {Sex.Male, "Eugene"},
+ {Sex.Female, "Kseniya"},
+ {Sex.Unsexed, "Xenia"}
+ };
+ public const int VoiceRange = 10; // how far voice goes in world units
+ public const int WhisperClearRange = 2; // how far whisper goes while still being understandable, in world units
+ public const int WhisperMuffledRange = 5; // how far whisper goes at all, in world units
+}
diff --git a/Content.Shared/_Art/TTS/TTSRadioPlayEvent.cs b/Content.Shared/_Art/TTS/TTSRadioPlayEvent.cs
new file mode 100644
index 00000000000..533fe091c3e
--- /dev/null
+++ b/Content.Shared/_Art/TTS/TTSRadioPlayEvent.cs
@@ -0,0 +1,21 @@
+using Content.Shared.Speech;
+using Robust.Shared.Prototypes;
+using Content.Shared.Inventory;
+
+namespace Content.Shared._Art.TTS;
+
+public sealed class TTSRadioPlayEvent : EntityEventArgs
+{
+ public string Message;
+ public string Voice;
+ public NetEntity? Source;
+ public NetEntity? Author;
+
+ public TTSRadioPlayEvent(string message, string voice, NetEntity? source, NetEntity? author)
+ {
+ Message = message;
+ Voice = voice;
+ Source = source;
+ Author = author;
+ }
+}
diff --git a/Content.Shared/_Art/TTS/TTSVoicePrototype.cs b/Content.Shared/_Art/TTS/TTSVoicePrototype.cs
new file mode 100644
index 00000000000..45769057654
--- /dev/null
+++ b/Content.Shared/_Art/TTS/TTSVoicePrototype.cs
@@ -0,0 +1,25 @@
+using Content.Shared.Humanoid;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared._Art.TTS;
+
+///
+/// Prototype represent available TTS voices
+///
+[Prototype("ttsVoice")]
+// ReSharper disable once InconsistentNaming
+public sealed partial class TTSVoicePrototype : IPrototype
+{
+ [IdDataField]
+ public string ID { get; private set; } = default!;
+
+ [DataField("name")]
+ public string Name { get; private set; } = string.Empty;
+
+ [ViewVariables(VVAccess.ReadWrite)]
+ [DataField("speaker", required: true)]
+ public string Speaker { get; private set; } = string.Empty;
+
+ [DataField("gender")]
+ public string Gender { get; private set; } = "none";
+}
diff --git a/Resources/_Art/TTS/tts-voices.yml b/Resources/_Art/TTS/tts-voices.yml
new file mode 100644
index 00000000000..133a26b938b
--- /dev/null
+++ b/Resources/_Art/TTS/tts-voices.yml
@@ -0,0 +1,1960 @@
+# (defun f ()
+# (interactive)
+# (save-mark-and-excursion
+# (mark-paragraph)
+# (save-mark-and-excursion
+# (setq has-gender (search-forward "gender: " (region-end) t))
+# )
+# (unless has-gender
+# (search-forward "speaker: ")
+# (set-mark (point))
+# (end-of-line)
+# (let ((speaker (buffer-substring (region-beginning) (point))))
+# (with-current-buffer "*git-show*"
+# (beginning-of-buffer)
+# (setq found (search-forward (format "speaker: %s" speaker) nil t))
+# (message (format "%s" found))
+# (when found
+# (mark-paragraph)
+# (search-forward "sex: ")
+# (setq gender (word-at-point))))
+# (end-of-paragraph-text)
+# (when found
+# (newline)
+# (insert (format " gender: %s" gender)))))))
+
+# #!/usr/bin/env python3
+# import json
+# voices = json.load(open("./voices.json"))["voices"]
+# for voice in voices:
+# print(f"""- type: ttsVoice
+# name: {voice['name']}
+# speaker: {voice['speakers'][0]}
+# id: {voice['speakers'][0]}
+# gender: {voice['gender']}""")
+# if voice['description'] != "":
+# print(f" description: {voice['description']}")
+# print()
+
+- type: ttsVoice
+ name: Папич
+ speaker: papich
+ id: papich
+ gender: male
+ description: Голос популярного стримера Папича
+
+- type: ttsVoice
+ name: Бэбэй
+ speaker: bebey
+ id: bebey
+ gender: male
+ description: Голос популярного стримера Бэбэя
+
+- type: ttsVoice
+ name: Дмитрий Пучков
+ speaker: puchkow
+ id: puchkow
+ gender: male
+
+- type: ttsVoice
+ name: Мориарти
+ speaker: moriarti
+ id: moriarti
+ gender: male
+
+- type: ttsVoice
+ name: Шарлотта
+ speaker: charlotte
+ id: charlotte
+ gender: female
+ description: Голос виртуального ютубера Charlotte Ch
+
+- type: ttsVoice
+ name: Планя
+ speaker: planya
+ id: planya
+ gender: female
+ description: Голос виртуального ютубера Planya Ch
+
+- type: ttsVoice
+ name: Мана
+ speaker: mana
+ id: mana
+ gender: female
+ description: Голос виртуального ютубера Mana Renewal
+
+- type: ttsVoice
+ name: Амина
+ speaker: amina
+ id: amina
+ gender: female
+ description: Голос виртуального ютубера St. Amina Renewal
+
+- type: ttsVoice
+ name: Джо Байден
+ speaker: biden
+ id: biden
+ gender: male
+ description: Голос американского президента Джо Байдена
+
+- type: ttsVoice
+ name: Барак Обама
+ speaker: obama
+ id: obama
+ gender: male
+ description: Голос американского президента Барака Обамы
+
+- type: ttsVoice
+ name: Дональд Трамп
+ speaker: trump
+ id: trump
+ gender: male
+ description: Голос американского президента Дональда Трампа
+
+- type: ttsVoice
+ name: Добакин
+ speaker: dbkn2
+ id: dbkn2
+ gender: male
+ description: Голос начинающего диктора Добакина
+
+- type: ttsVoice
+ name: Хреноид
+ speaker: xrenoid
+ id: xrenoid
+ gender: male
+ description: Голос начинающего диктора Хреноида
+
+- type: ttsVoice
+ name: PlayBoyzTV
+ speaker: playboyztv
+ id: playboyztv
+ gender: male
+ description: Голос канала PlayBoyzTV
+
+- type: ttsVoice
+ name: Уоллес Брин
+ speaker: briman
+ id: briman
+ gender: male
+ description: Голос персонажа Уоллес Брин из игры Half-Life 2
+
+- type: ttsVoice
+ name: Отец Григорий
+ speaker: father_grigori
+ id: father_grigori
+ gender: male
+ description: Голос персонажа Отец Григорий из игры Half-Life 2
+
+- type: ttsVoice
+ name: Доктор Кляйнер
+ speaker: kleiner
+ id: kleiner
+ gender: male
+ description: Голос персонажа Доктор Кляйнер из игры Half-Life 2
+
+- type: ttsVoice
+ name: Джудит Моссман
+ speaker: mossman
+ id: mossman
+ gender: female
+ description: Голос персонажа Джудит Моссман из игры Half-Life 2
+
+- type: ttsVoice
+ name: Илай Вэнс
+ speaker: vance
+ id: vance
+ gender: male
+ description: Голос персонажа Илай Вэнс из игры Half-Life 2
+
+- type: ttsVoice
+ name: Аликс Вэнс
+ speaker: alyx
+ id: alyx
+ gender: female
+ description: Голос персонажа Аликс Вэнс из игры Half-Life 2
+
+- type: ttsVoice
+ name: G-Man
+ speaker: gman
+ id: gman
+ gender: male
+ description: Голос персонажа G-Man из игры Half-Life 2
+
+- type: ttsVoice
+ name: Барни Калхун
+ speaker: barni
+ id: barni
+ gender: male
+ description: Голос персонажа Барни Калхун из игры Half-Life 2
+
+- type: ttsVoice
+ name: Неко-Арк
+ speaker: neco
+ id: neco
+ gender: female
+ description: Голос персонажа Неко-Арк из Fate
+
+- type: ttsVoice
+ name: Неко-Арк 2
+ speaker: neco_arc_2
+ id: neco_arc_2
+ gender: female
+ description: Голос персонажа Неко-Арк из Fate
+
+- type: ttsVoice
+ name: Злая Неко-Арк
+ speaker: angry_neco_arc
+ id: angry_neco_arc
+ gender: female
+ description: Злой голос персонажа Неко-Арк из Fate
+
+- type: ttsVoice
+ name: Житель
+ speaker: villager
+ id: villager
+ gender: male
+ description: Голос персонажа Житель из игры Minecraft
+
+- type: ttsVoice
+ name: Сквидвард
+ speaker: squidward
+ id: squidward
+ gender: male
+ description: Голос персонажа Сквидвард из мультфильма Губка Боб
+
+- type: ttsVoice
+ name: SentryBot
+ speaker: sentrybot
+ id: sentrybot
+ gender: male
+ description: Голос персонажа SentryBot из Fallout 3
+
+- type: ttsVoice
+ name: Мойра Браун
+ speaker: moira_brown
+ id: moira_brown
+ gender: female
+ description: Голос персонажа Мойра Браун из Fallout 3
+
+- type: ttsVoice
+ name: Робер МакКриди
+ speaker: robert_maccready
+ id: robert_maccready
+ gender: male
+ description: Голос персонажа Робер МакКриди из Fallout 3
+
+- type: ttsVoice
+ name: Тридогнайт
+ speaker: threedog
+ id: threedog
+ gender: male
+ description: Голос персонажа Тридогнайт из Fallout 3
+
+- type: ttsVoice
+ name: Тридогнайт Радио
+ speaker: threedog_radio
+ id: threedog_radio
+ gender: male
+ description: Голос персонажа Тридогнайт версия по радио из Fallout 3
+
+- type: ttsVoice
+ name: Мистер Помощник
+ speaker: mister_handy_fl3
+ id: mister_handy_fl3
+ gender: male
+ description: Голос персонажа Мистер Помощник из Fallout 3
+
+- type: ttsVoice
+ name: Мистер Храбрец
+ speaker: mister_gutsy_fl3
+ id: mister_gutsy_fl3
+ gender: male
+ description: Голос персонажа Мистер Храбрец из Fallout 3
+
+- type: ttsVoice
+ name: Джерико
+ speaker: jericho_fl3
+ id: jericho_fl3
+ gender: male
+ description: Голос персонажа Джерико из Fallout 3
+
+- type: ttsVoice
+ name: Старейшина Лайонс
+ speaker: elder_lyons_fl3
+ id: elder_lyons_fl3
+ gender: male
+ description: Голос персонажа Старейшина Лайонс из Fallout 3
+
+- type: ttsVoice
+ name: Сара Лайонс
+ speaker: sarah_lyons_fl3
+ id: sarah_lyons_fl3
+ gender: female
+ description: Голос персонажа Сара Лайонс из Fallout 3
+
+- type: ttsVoice
+ name: Колин Мориарти
+ speaker: colin_moriarty_fl3
+ id: colin_moriarty_fl3
+ gender: male
+ description: Голос персонажа Колин Мориарти из Fallout 3
+
+- type: ttsVoice
+ name: Джон Генри Эдем
+ speaker: john_henry_eden_fl3
+ id: john_henry_eden_fl3
+ gender: male
+ description: Голос персонажа Джон Генри Эдем из Fallout 3
+
+- type: ttsVoice
+ name: Супер-Мутант
+ speaker: super_mutant_fl3
+ id: super_mutant_fl3
+ gender: male
+ description: Голос персонажа Супер-Мутант из Fallout 3
+
+- type: ttsVoice
+ name: Полина Морозова
+ speaker: polina
+ id: polina
+ gender: female
+ description: Голос персонажа Полина Морозова из игры Tiny Bunny
+
+- type: ttsVoice
+ name: Рома Пятифанов
+ speaker: romka
+ id: romka
+ gender: male
+ description: Голос персонажа Рома Пятифанов из игры Tiny Bunny
+
+- type: ttsVoice
+ name: Борис Петров
+ speaker: boris_petrov_father_tb
+ id: boris_petrov_father_tb
+ gender: male
+ description: Голос персонажа Борис Петров из игры Tiny Bunny
+
+- type: ttsVoice
+ name: Карина Петрова
+ speaker: karina_petrova_tb
+ id: karina_petrova_tb
+ gender: female
+ description: Голос персонажа Карина Петрова из игры Tiny Bunny
+
+- type: ttsVoice
+ name: Катя Смирнова
+ speaker: kate_smirnova_tb
+ id: kate_smirnova_tb
+ gender: female
+ description: Голос персонажа Катя Смирнова из игры Tiny Bunny
+
+- type: ttsVoice
+ name: Семён Бабурин
+ speaker: semen_baburin_tb
+ id: semen_baburin_tb
+ gender: male
+ description: Голос персонажа Семён Бабурин из игры Tiny Bunny
+
+- type: ttsVoice
+ name: Лейтенант Тихонов
+ speaker: tihonov_tb
+ id: tihonov_tb
+ gender: male
+ description: Голос персонажа Лейтенант Тихонов из игры Tiny Bunny
+
+- type: ttsVoice
+ name: Цицерон
+ speaker: cicero
+ id: cicero
+ gender: male
+ description: Голос персонажа Цицерон из игры TES 5 Skyrim
+
+- type: ttsVoice
+ name: Шеогорат
+ speaker: sheogorath
+ id: sheogorath
+ gender: male
+ description: Голос персонажа Шеогорат из игры TES 5 Skyrim
+
+- type: ttsVoice
+ name: Кодлак Белая Грива
+ speaker: kodlakwhitemane
+ id: kodlakwhitemane
+ gender: male
+ description: Голос персонажа Кодлак Белая Грива из игры TES 5 Skyrim
+
+- type: ttsVoice
+ name: Каджит
+ speaker: khajiit
+ id: khajiit
+ gender: male
+ description: Голос персонажа Каджит из игры TES 5 Skyrim
+
+- type: ttsVoice
+ name: Эленвен
+ speaker: elenwen
+ id: elenwen
+ gender: female
+ description: Голос персонажа Эленвен из игры TES 5 Skyrim
+
+- type: ttsVoice
+ name: Тит Мид II
+ speaker: emperor
+ id: emperor
+ gender: male
+ description: Голос персонажа Тит Мид II из игры TES 5 Skyrim
+
+- type: ttsVoice
+ name: Стражник
+ speaker: guard
+ id: guard
+ gender: male
+ description: Голос Стражников из игры TES 5 Skyrim
+
+- type: ttsVoice
+ name: Ворожея
+ speaker: hagraven
+ id: hagraven
+ gender: male
+ description: Голос Ворожей из игры TES 5 Skyrim
+
+- type: ttsVoice
+ name: Хермеус Мора
+ speaker: hermaeus_mora
+ id: hermaeus_mora
+ gender: male
+ description: Голос персонажа Хермеус Мора из игры TES 5 Skyrim
+
+- type: ttsVoice
+ name: Норд
+ speaker: nord
+ id: nord
+ gender: male
+ description: Голос Нордов из игры TES 5 Skyrim
+
+- type: ttsVoice
+ name: Ульфрик Буревестник
+ speaker: ulfric
+ id: ulfric
+ gender: male
+ description: Голос персонажа Ульфрик Буревестник из игры TES 5 Skyrim
+
+- type: ttsVoice
+ name: Астрид
+ speaker: astrid
+ id: astrid
+ gender: female
+ description: Голос персонажа Астрид из игры TES 5 Skyrim
+
+- type: ttsVoice
+ name: Мавен Чёрный Вереск
+ speaker: maven
+ id: maven
+ gender: female
+ description: Голос персонажа Мавен Чёрный Вереск из игры TES 5 Skyrim
+
+- type: ttsVoice
+ name: Назир
+ speaker: nazir
+ id: nazir
+ gender: male
+ description: Голос персонажа Назир из игры TES 5 Skyrim
+
+- type: ttsVoice
+ name: Женщина командир
+ speaker: female_commander
+ id: female_commander
+ gender: female
+ description: Голос женских персонажей командиров из игры TES 5 Skyrim
+
+- type: ttsVoice
+ name: Лорд Харкон
+ speaker: lord_harkon
+ id: lord_harkon
+ gender: male
+ description: Голос персонажа Лорд Харкон из игры TES 5 Skyrim
+
+- type: ttsVoice
+ name: Серана
+ speaker: serana
+ id: serana
+ gender: female
+ description: Голос персонажа Серана из игры TES 5 Skyrim
+
+- type: ttsVoice
+ name: GLaDOS
+ speaker: glados
+ id: glados
+ gender: female
+ description: Голос персонажа GLaDOS из игры Portal 2
+
+- type: ttsVoice
+ name: Модуль Приключений
+ speaker: adventure_core
+ id: adventure_core
+ gender: male
+ description: Голос персонажа Модуль Приключений из игры Portal 2
+
+- type: ttsVoice
+ name: Модуль Фактов
+ speaker: fact_core
+ id: fact_core
+ gender: male
+ description: Голос персонажа Модуль Фактов из игры Portal 2
+
+- type: ttsVoice
+ name: Модуль Космоса
+ speaker: space_core
+ id: space_core
+ gender: male
+ description: Голос персонажа Модуль Космоса из игры Portal 2
+
+- type: ttsVoice
+ name: Турель
+ speaker: turret_floor
+ id: turret_floor
+ gender: female
+ description: Голос персонажа Турель из игры Portal 2
+
+- type: ttsVoice
+ name: Геральт из Ривии
+ speaker: geralt
+ id: geralt
+ gender: male
+ description: Голос персонажа Геральт из Ривии из игры The Witcher 3
+
+- type: ttsVoice
+ name: Цирилла
+ speaker: cirilla
+ id: cirilla
+ gender: female
+ description: Голос персонажа Цирилла из игры The Witcher 3
+
+- type: ttsVoice
+ name: Керис ан Крайт
+ speaker: cerys
+ id: cerys
+ gender: female
+ description: Голос персонажа Керис ан Крайт из игры The Witcher 3
+
+- type: ttsVoice
+ name: Ламберт
+ speaker: lambert
+ id: lambert
+ gender: male
+ description: Голос персонажа Ламберт из игры The Witcher 3
+
+- type: ttsVoice
+ name: Трисс
+ speaker: triss
+ id: triss
+ gender: female
+ description: Голос персонажа Трисс из игры The Witcher 3
+
+- type: ttsVoice
+ name: Ковир Ноблеман
+ speaker: kovir_nobleman
+ id: kovir_nobleman
+ gender: male
+ description: Голос персонажа Ковир Ноблеман из игры The Witcher 3
+
+- type: ttsVoice
+ name: Золтан Хивай
+ speaker: zoltan_chivay
+ id: zoltan_chivay
+ gender: male
+ description: Голос персонажа Золтан Хивай из игры The Witcher 3
+
+- type: ttsVoice
+ name: Весемир
+ speaker: vesemir
+ id: vesemir
+ gender: male
+ description: Голос персонажа Весемир из игры The Witcher 3
+
+- type: ttsVoice
+ name: Гильом де Лонфаль
+ speaker: guillaume_de_launfal
+ id: guillaume_de_launfal
+ gender: male
+ description: Голос персонажа Гильом де Лонфаль из игры The Witcher 3
+
+- type: ttsVoice
+ name: Филиппа Эйльхарт
+ speaker: philippa_eilhart
+ id: philippa_eilhart
+ gender: female
+ description: Голос персонажа Филиппа Эйльхарт из игры The Witcher 3
+
+- type: ttsVoice
+ name: Эвальд Борсоди
+ speaker: ewald_borsodi
+ id: ewald_borsodi
+ gender: male
+ description: Голос персонажа Эвальд Борсоди из игры The Witcher 3
+
+- type: ttsVoice
+ name: Лютик
+ speaker: dandelion
+ id: dandelion
+ gender: male
+ description: Голос персонажа Лютик из игры The Witcher 3
+
+- type: ttsVoice
+ name: Шани
+ speaker: shani
+ id: shani
+ gender: female
+ description: Голос персонажа Шани из игры The Witcher 3
+
+- type: ttsVoice
+ name: Азир
+ speaker: azir
+ id: azir
+ gender: male
+ description: Голос персонажа Азир из игры League of Legends
+
+- type: ttsVoice
+ name: Экко
+ speaker: ekko
+ id: ekko
+ gender: male
+ description: Голос персонажа Экко из игры League of Legends
+
+- type: ttsVoice
+ name: Твич
+ speaker: twitch
+ id: twitch
+ gender: male
+ description: Голос персонажа Твич из игры League of Legends
+
+- type: ttsVoice
+ name: Зиггс
+ speaker: ziggs
+ id: ziggs
+ gender: male
+ description: Голос персонажа Зиггс из игры League of Legends
+
+- type: ttsVoice
+ name: Кэйтлин
+ speaker: caitlyn
+ id: caitlyn
+ gender: female
+ description: Голос персонажа Кэйтлин из игры League of Legends
+
+- type: ttsVoice
+ name: Артас Менетил
+ speaker: arthas
+ id: arthas
+ gender: male
+ description: Голос персонажа Артас Менетил из игры Warcraft 3
+
+- type: ttsVoice
+ name: Иллидан Ярость Бури
+ speaker: illidan
+ id: illidan
+ gender: male
+ description: Голос персонажа Иллидан Ярость Бури из игры Warcraft 3
+
+- type: ttsVoice
+ name: Рексар
+ speaker: rexxar
+ id: rexxar
+ gender: male
+ description: Голос персонажа Рексар из игры Warcraft 3
+
+- type: ttsVoice
+ name: Вол'джин
+ speaker: voljin
+ id: voljin
+ gender: male
+ description: Голос персонажа Вол'джин из игры Warcraft 3
+
+- type: ttsVoice
+ name: Бандит
+ speaker: bandit
+ id: bandit
+ gender: male
+ description: Голос персонажа Бандита из игры S.T.A.L.K.E.R.
+
+- type: ttsVoice
+ name: Лесник
+ speaker: forester
+ id: forester
+ gender: male
+ description: Голос персонажа Лесник из игры S.T.A.L.K.E.R.
+
+- type: ttsVoice
+ name: Сидорович
+ speaker: sidorovich
+ id: sidorovich
+ gender: male
+ description: Голос персонажа Сидорович из игры S.T.A.L.K.E.R.
+
+- type: ttsVoice
+ name: Стрелок
+ speaker: strelok
+ id: strelok
+ gender: male
+ description: Голос персонажа Стрелок из игры S.T.A.L.K.E.R.
+
+- type: ttsVoice
+ name: Трэйсер
+ speaker: tracer
+ id: tracer
+ gender: female
+ description: Голос персонажа Трэйсер из игры Overwatch
+
+- type: ttsVoice
+ name: Солдат
+ speaker: soldier
+ id: soldier
+ gender: male
+ description: Голос персонажа Солдат из Team Fortress 2 из английской озвучки
+
+- type: ttsVoice
+ name: Инженер
+ speaker: engineer
+ id: engineer
+ gender: male
+ description: Голос персонажа Инженер из игры Team Fortress 2
+
+- type: ttsVoice
+ name: Хэви
+ speaker: heavy
+ id: heavy
+ gender: male
+ description: Голос персонажа Хэви из игры Team Fortress 2
+
+- type: ttsVoice
+ name: Медик
+ speaker: medic
+ id: medic
+ gender: male
+ description: Голос персонажа Медик из игры Team Fortress 2
+
+- type: ttsVoice
+ name: Подрывник
+ speaker: demoman
+ id: demoman
+ gender: male
+ description: Голос персонажа Подрывник из игры Team Fortress 2
+
+- type: ttsVoice
+ name: Снайпер
+ speaker: sniper
+ id: sniper
+ gender: male
+ description: Голос персонажа Снайпер из игры Team Fortress 2
+
+- type: ttsVoice
+ name: Шпион
+ speaker: spy
+ id: spy
+ gender: male
+ description: Голос персонажа Шпион из игры Team Fortress 2
+
+- type: ttsVoice
+ name: Каратель
+ speaker: punisher
+ id: punisher
+ gender: male
+ description: Голос персонажа Каратель из игры The Punisher
+
+- type: ttsVoice
+ name: Джонни Сильверхенд
+ speaker: johnny
+ id: johnny
+ gender: male
+ description: Голос персонажа Джонни Сильверхенд из игры Cyberpunk 2077
+
+- type: ttsVoice
+ name: Панам Палмер
+ speaker: panam
+ id: panam
+ gender: female
+ description: Голос персонажа Панам Палмер из игры Cyberpunk 2077
+
+- type: ttsVoice
+ name: Ви
+ speaker: v_female
+ id: v_female
+ gender: female
+ description: Голос персонажа Ви из игры Cyberpunk 2077
+
+- type: ttsVoice
+ name: Джуди Альварес
+ speaker: judy
+ id: judy
+ gender: female
+ description: Голос персонажа Джуди Альварес из игры Cyberpunk 2077
+
+- type: ttsVoice
+ name: Митч Андерсон
+ speaker: mitch
+ id: mitch
+ gender: male
+ description: Голос персонажа Митч Андерсон из игры Cyberpunk 2077
+
+- type: ttsVoice
+ name: Скиппи
+ speaker: skippy
+ id: skippy
+ gender: male
+ description: Голос персонажа Скиппи из игры Cyberpunk 2077
+
+- type: ttsVoice
+ name: Джеки Уэллс
+ speaker: jackie
+ id: jackie
+ gender: male
+ description: Голос персонажа Джеки Уэллс из игры Cyberpunk 2077
+
+- type: ttsVoice
+ name: Майко Маэда
+ speaker: maiko
+ id: maiko
+ gender: female
+ description: Голос персонажа Майко Маэда из игры Cyberpunk 2077
+
+- type: ttsVoice
+ name: Брендан
+ speaker: brendan
+ id: brendan
+ gender: male
+ description: Голос персонажа Брендан из игры Cyberpunk 2077
+
+- type: ttsVoice
+ name: Нэнси Хартли
+ speaker: nancy_hartley
+ id: nancy_hartley
+ gender: female
+ description: Голос персонажа Нэнси Хартли из игры Cyberpunk 2077
+
+- type: ttsVoice
+ name: Освальд Форрест
+ speaker: oswald_forrest
+ id: oswald_forrest
+ gender: male
+ description: Голос персонажа Освальд Форрест из игры Cyberpunk 2077
+
+- type: ttsVoice
+ name: Стив Санчес
+ speaker: steve
+ id: steve
+ gender: male
+ description: Голос персонажа Стив Санчес из игры Cyberpunk 2077
+
+- type: ttsVoice
+ name: Сержант Дорнан
+ speaker: dornan
+ id: dornan
+ gender: male
+ description: Голос персонажа Сержант Дорнан из игры Fallout 2
+
+- type: ttsVoice
+ name: Офицер Анклава
+ speaker: officer_enclave
+ id: officer_enclave
+ gender: male
+ description: Голос персонажа Офицер Анклава из игры Fallout 2
+
+- type: ttsVoice
+ name: Дик Ричардсон
+ speaker: richardson
+ id: richardson
+ gender: male
+ description: Голос персонажа Ричард «Дик» Ричардсон из игры Fallout 2
+
+- type: ttsVoice
+ name: Бутч Харрис
+ speaker: butch
+ id: butch
+ gender: male
+ description: Голос персонажа Бутч Харрис из игры Fallout 2
+
+- type: ttsVoice
+ name: Маркус
+ speaker: marcus
+ id: marcus
+ gender: male
+ description: Голос персонажа Маркус из игры Fallout 2
+
+- type: ttsVoice
+ name: Сулик
+ speaker: sulik
+ id: sulik
+ gender: male
+ description: Голос персонажа Сулик из игры Fallout 2
+
+- type: ttsVoice
+ name: Майрон
+ speaker: myron
+ id: myron
+ gender: male
+ description: Голос персонажа Майрон из игры Fallout 2
+
+- type: ttsVoice
+ name: Добрая Талия
+ speaker: good_thalya
+ id: good_thalya
+ gender: female
+ description: Голос персонажа Добрая Талия из игры Dungeons 3
+
+- type: ttsVoice
+ name: Злая Талия
+ speaker: evil_thalya
+ id: evil_thalya
+ gender: female
+ description: Голос персонажа Злая Талия из игры Dungeons 3
+
+- type: ttsVoice
+ name: Рассказчик
+ speaker: narrator_d3
+ id: narrator_d3
+ gender: male
+ description: Голос персонажа Рассказчик из игры Dungeons 3
+
+- type: ttsVoice
+ name: Чувак
+ speaker: dude
+ id: dude
+ gender: male
+ description: Голос персонажа Чувак из игры Postal 2
+
+- type: ttsVoice
+ name: Андуин Ринн
+ speaker: anduin
+ id: anduin
+ gender: male
+ description: Голос персонажа Андуин Ринн из игры Hearthstone
+
+- type: ttsVoice
+ name: Бру'кан
+ speaker: brukan
+ id: brukan
+ gender: male
+ description: Голос персонажа Бру'кан из игры Hearthstone
+
+- type: ttsVoice
+ name: Гаррош Адский Крик
+ speaker: garrosh
+ id: garrosh
+ gender: male
+ description: Голос персонажа Гаррош Адский Крик из игры Hearthstone
+
+- type: ttsVoice
+ name: Джайна Праудмур
+ speaker: jaina
+ id: jaina
+ gender: female
+ description: Голос персонажа Джайна Праудмур из игры Hearthstone
+
+- type: ttsVoice
+ name: Утер Светоносный
+ speaker: uther_hs
+ id: uther_hs
+ gender: male
+ description: Голос персонажа Утер Светоносный из игры Hearthstone
+
+- type: ttsVoice
+ name: Адъютант
+ speaker: adjutant
+ id: adjutant
+ gender: female
+ description: Голос персонажа Адъютант из игры StarCraft 2
+
+- type: ttsVoice
+ name: Ариэль Хэнсон
+ speaker: hanson
+ id: hanson
+ gender: female
+ description: Голос персонажа Ариэль Хэнсон из игры StarCraft 2
+
+- type: ttsVoice
+ name: Бралик
+ speaker: bralik
+ id: bralik
+ gender: male
+ description: Голос персонажа Бралик из игры StarCraft 2
+
+- type: ttsVoice
+ name: Мэтт Хорнер
+ speaker: horner
+ id: horner
+ gender: male
+ description: Голос персонажа Мэтт Хорнер из игры StarCraft 2
+
+- type: ttsVoice
+ name: Габриэль Тош
+ speaker: tosh
+ id: tosh
+ gender: male
+ description: Голос персонажа Габриэль Тош из игры StarCraft 2
+
+- type: ttsVoice
+ name: Тайкус Финдли
+ speaker: tychus
+ id: tychus
+ gender: male
+ description: Голос персонажа Тайкус Финдли из игры StarCraft 2
+
+- type: ttsVoice
+ name: Амит Таккар
+ speaker: amitkakkar
+ id: amitkakkar
+ gender: male
+ description: Голос персонажа Амит Таккар из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Элеазар Фиг
+ speaker: eleazarfig
+ id: eleazarfig
+ gender: male
+ description: Голос персонажа Элеазар Фиг из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Эрни Ларк
+ speaker: ernielark
+ id: ernielark
+ gender: male
+ description: Голос персонажа Эрни Ларк из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Игнатия Уайлдсмит
+ speaker: ignatiaflootravel
+ id: ignatiaflootravel
+ gender: female
+ description: Голос персонажа Игнатия Уайлдсмит из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Лодгок
+ speaker: lodgok
+ id: lodgok
+ gender: male
+ description: Голос персонажа Лодгок из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Финеас Найджелус Блэк
+ speaker: phineasblack
+ id: phineasblack
+ gender: male
+ description: Голос персонажа Финеас Найджелус Блэк из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Ранрок
+ speaker: ranrak
+ id: ranrak
+ gender: male
+ description: Голос персонажа Ранрок из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Виктор Руквуд
+ speaker: victorrookwood
+ id: victorrookwood
+ gender: male
+ description: Голос персонажа Виктор Руквуд из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Зенобия Ноук
+ speaker: zenobianoke
+ id: zenobianoke
+ gender: female
+ description: Голос персонажа Зенобия Ноук из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Глэдвин Мун
+ speaker: gladwinmoon
+ id: gladwinmoon
+ gender: male
+ description: Голос персонажа Глэдвин Мун из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Матильда Уизли
+ speaker: matildaweasley
+ id: matildaweasley
+ gender: female
+ description: Голос персонажа Матильда Уизли из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Натсай Онай
+ speaker: natsaionai
+ id: natsaionai
+ gender: female
+ description: Голос персонажа Натсай Онай из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Оминис Мракс
+ speaker: ominisgaunt
+ id: ominisgaunt
+ gender: male
+ description: Голос персонажа Оминис Мракс из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Поппи Добринг
+ speaker: poppysweeting
+ id: poppysweeting
+ gender: female
+ description: Голос персонажа Поппи Добринг из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Гоблин В
+ speaker: generic_goblin_c
+ id: generic_goblin_c
+ gender: male
+ description: Голос персонажа Гоблин В из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Дина Гекат
+ speaker: dinah_hecat
+ id: dinah_hecat
+ gender: female
+ description: Голос персонажа Дина Гекат из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Эзоп Шарп
+ speaker: aesop_sharp
+ id: aesop_sharp
+ gender: male
+ description: Голос персонажа Эзоп Шарп из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Абрахам Ронен
+ speaker: abraham_ronen
+ id: abraham_ronen
+ gender: male
+ description: Голос персонажа Абрахам Ронен из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Саманта Дейл
+ speaker: samantha_dale
+ id: samantha_dale
+ gender: female
+ description: Голос персонажа Саманта Дейл из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Сирона Райан
+ speaker: sirona_ryan
+ id: sirona_ryan
+ gender: female
+ description: Голос персонажа Сирона Райан из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Софрония Франклин
+ speaker: sophronia_franklin
+ id: sophronia_franklin
+ gender: female
+ description: Голос персонажа Софрония Франклин из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Эверетт Клоптон
+ speaker: everett_clopton
+ id: everett_clopton
+ gender: male
+ description: Голос персонажа Эверетт Клоптон из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Гоблин Б
+ speaker: generic_goblin_b
+ id: generic_goblin_b
+ gender: male
+ description: Голос персонажа Гоблин Б из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Домовой эльф из святилища
+ speaker: sanctuary_house_elf
+ id: sanctuary_house_elf
+ gender: male
+ description: Голос персонажа Домовой эльф из святилища из игры Hogwarts Legacy
+
+- type: ttsVoice
+ name: Abaddon
+ speaker: abaddon_dota_2
+ id: abaddon_dota_2
+ gender: male
+ description: Голос персонажа Abaddon из игры Dota 2
+
+- type: ttsVoice
+ name: Achemist
+ speaker: alchemist_dota_2
+ id: alchemist_dota_2
+ gender: male
+ description: Голос персонажа Achemist из игры Dota 2
+
+- type: ttsVoice
+ name: Anti-Mage
+ speaker: anti-mage_dota_2
+ id: anti-mage_dota_2
+ gender: male
+ description: Голос персонажа Anti-Mage из игры Dota 2
+
+- type: ttsVoice
+ name: Arc Warden
+ speaker: arc_warden_dota_2
+ id: arc_warden_dota_2
+ gender: male
+ description: Голос персонажа Arc Warden из игры Dota 2
+
+- type: ttsVoice
+ name: Batrider
+ speaker: batrider_dota_2
+ id: batrider_dota_2
+ gender: male
+ description: Голос персонажа Batrider из игры Dota 2
+
+- type: ttsVoice
+ name: Bloodseeker
+ speaker: bloodseeker_dota_2
+ id: bloodseeker_dota_2
+ gender: male
+ description: Голос персонажа Bloodseeker из игры Dota 2
+
+- type: ttsVoice
+ name: Bounty Hunter
+ speaker: bounty_hunter_dota_2
+ id: bounty_hunter_dota_2
+ gender: male
+ description: Голос персонажа Bounty Hunter из игры Dota 2
+
+- type: ttsVoice
+ name: Bristleback
+ speaker: bristleback_dota_2
+ id: bristleback_dota_2
+ gender: male
+ description: Голос персонажа Bristleback из игры Dota 2
+
+- type: ttsVoice
+ name: Broodmother
+ speaker: broodmother_dota_2
+ id: broodmother_dota_2
+ gender: female
+ description: Голос персонажа Broodmother из игры Dota 2
+
+- type: ttsVoice
+ name: Centaur Warrunner
+ speaker: centaur_warrunner_dota_2
+ id: centaur_warrunner_dota_2
+ gender: male
+ description: Голос персонажа Centaur Warrunner из игры Dota 2
+
+- type: ttsVoice
+ name: Clinkz
+ speaker: clinkz_dota_2
+ id: clinkz_dota_2
+ gender: male
+ description: Голос персонажа Clinkz из игры Dota 2
+
+- type: ttsVoice
+ name: Clockwerk
+ speaker: clockwerk_dota_2
+ id: clockwerk_dota_2
+ gender: male
+ description: Голос персонажа Clockwerk из игры Dota 2
+
+- type: ttsVoice
+ name: Crystal Maiden
+ speaker: crystal_maiden_dota_2
+ id: crystal_maiden_dota_2
+ gender: female
+ description: Голос персонажа Crystal Maiden из игры Dota 2
+
+- type: ttsVoice
+ name: Dazzle
+ speaker: dazzle_dota_2
+ id: dazzle_dota_2
+ gender: male
+ description: Голос персонажа Dazzle из игры Dota 2
+
+- type: ttsVoice
+ name: Disruptor
+ speaker: disruptor_dota_2
+ id: disruptor_dota_2
+ gender: male
+ description: Голос персонажа Disruptor из игры Dota 2
+
+- type: ttsVoice
+ name: Doom
+ speaker: doom_dota_2
+ id: doom_dota_2
+ gender: male
+ description: Голос персонажа Doom из игры Dota 2
+
+- type: ttsVoice
+ name: Dragon Knight
+ speaker: dragon_knight_dota_2
+ id: dragon_knight_dota_2
+ gender: male
+ description: Голос персонажа Dragon Knight из игры Dota 2
+
+- type: ttsVoice
+ name: Dragon Knight Dragon
+ speaker: dragon_knight_dragon_dota_2
+ id: dragon_knight_dragon_dota_2
+ gender: male
+ description: Голос персонажа Dragon Knight Dragon из игры Dota 2
+
+- type: ttsVoice
+ name: Drow Ranger
+ speaker: drow_ranger_dota_2
+ id: drow_ranger_dota_2
+ gender: female
+ description: Голос персонажа Drow Ranger из игры Dota 2
+
+- type: ttsVoice
+ name: Earthshaker
+ speaker: earthshaker_dota_2
+ id: earthshaker_dota_2
+ gender: male
+ description: Голос персонажа Earthshaker из игры Dota 2
+
+- type: ttsVoice
+ name: Ember Spirit
+ speaker: ember_spirit_dota_2
+ id: ember_spirit_dota_2
+ gender: male
+ description: Голос персонажа Ember Spirit из игры Dota 2
+
+- type: ttsVoice
+ name: Enigma
+ speaker: enigma_dota_2
+ id: enigma_dota_2
+ gender: male
+ description: Голос персонажа Enigma из игры Dota 2
+
+- type: ttsVoice
+ name: Gyrocopter
+ speaker: gyrocopter_dota_2
+ id: gyrocopter_dota_2
+ gender: male
+ description: Голос персонажа Gyrocopter из игры Dota 2
+
+- type: ttsVoice
+ name: Huskar
+ speaker: huskar_dota_2
+ id: huskar_dota_2
+ gender: male
+ description: Голос персонажа Huskar из игры Dota 2
+
+- type: ttsVoice
+ name: Invoker
+ speaker: invoker_dota_2
+ id: invoker_dota_2
+ gender: male
+ description: Голос персонажа Invoker из игры Dota 2
+
+- type: ttsVoice
+ name: Juggernaut
+ speaker: juggernaut_dota_2
+ id: juggernaut_dota_2
+ gender: male
+ description: Голос персонажа Juggernaut из игры Dota 2
+
+- type: ttsVoice
+ name: Keeper Of The Light
+ speaker: keeper_of_the_light_dota_2
+ id: keeper_of_the_light_dota_2
+ gender: male
+ description: Голос персонажа Keeper Of The Light из игры Dota 2
+
+- type: ttsVoice
+ name: Kunkka
+ speaker: kunkka_dota_2
+ id: kunkka_dota_2
+ gender: male
+ description: Голос персонажа Kunkka из игры Dota 2
+
+- type: ttsVoice
+ name: Legion Commander Demon
+ speaker: legion_commander_demon_dota_2
+ id: legion_commander_demon_dota_2
+ gender: female
+ description: Голос персонажа Legion Commander Demon из игры Dota 2
+
+- type: ttsVoice
+ name: Legion Commander
+ speaker: legion_commander_dota_2
+ id: legion_commander_dota_2
+ gender: female
+ description: Голос персонажа Legion Commander из игры Dota 2
+
+- type: ttsVoice
+ name: Lich
+ speaker: lich_dota_2
+ id: lich_dota_2
+ gender: male
+ description: Голос персонажа Lich из игры Dota 2
+
+- type: ttsVoice
+ name: Lina
+ speaker: lina_dota_2
+ id: lina_dota_2
+ gender: female
+ description: Голос персонажа Lina из игры Dota 2
+
+- type: ttsVoice
+ name: Luna
+ speaker: luna_dota_2
+ id: luna_dota_2
+ gender: female
+ description: Голос персонажа Luna из игры Dota 2
+
+- type: ttsVoice
+ name: Lycan
+ speaker: lycan_dota_2
+ id: lycan_dota_2
+ gender: male
+ description: Голос персонажа Lycan из игры Dota 2
+
+- type: ttsVoice
+ name: Lycan Wolf
+ speaker: lycan_wolf_dota_2
+ id: lycan_wolf_dota_2
+ gender: male
+ description: Голос персонажа Lycan Wolf из игры Dota 2
+
+- type: ttsVoice
+ name: Meepo
+ speaker: meepo_dota_2
+ id: meepo_dota_2
+ gender: male
+ description: Голос персонажа Meepo из игры Dota 2
+
+- type: ttsVoice
+ name: Mirana
+ speaker: mirana_dota_2
+ id: mirana_dota_2
+ gender: female
+ description: Голос персонажа Mirana из игры Dota 2
+
+- type: ttsVoice
+ name: Monkey King Crown
+ speaker: monkey_king_crown_dota_2
+ id: monkey_king_crown_dota_2
+ gender: male
+ description: Голос персонажа Monkey King Crown из игры Dota 2
+
+- type: ttsVoice
+ name: Monkey King
+ speaker: monkey_king_dota_2
+ id: monkey_king_dota_2
+ gender: male
+ description: Голос персонажа Monkey King из игры Dota 2
+
+- type: ttsVoice
+ name: Necrophos
+ speaker: necrophos_dota_2
+ id: necrophos_dota_2
+ gender: male
+ description: Голос персонажа Necrophos из игры Dota 2
+
+- type: ttsVoice
+ name: Nyx Assassin
+ speaker: nyx_assassin_dota_2
+ id: nyx_assassin_dota_2
+ gender: male
+ description: Голос персонажа Nyx Assassin из игры Dota 2
+
+- type: ttsVoice
+ name: Omniknight
+ speaker: omniknight_dota_2
+ id: omniknight_dota_2
+ gender: male
+ description: Голос персонажа Omniknight из игры Dota 2
+
+- type: ttsVoice
+ name: Outworld Destroyer
+ speaker: outworld_destroyer_dota_2
+ id: outworld_destroyer_dota_2
+ gender: male
+ description: Голос персонажа Outworld Destroyer из игры Dota 2
+
+- type: ttsVoice
+ name: Phantom Assassin Arcana
+ speaker: phantom_assassin_arcana_dota_2
+ id: phantom_assassin_arcana_dota_2
+ gender: female
+ description: Голос персонажа Phantom Assassin Arcana из игры Dota 2
+
+- type: ttsVoice
+ name: Phantom Assassin
+ speaker: phantom_assassin_dota_2
+ id: phantom_assassin_dota_2
+ gender: female
+ description: Голос персонажа Phantom Assassin из игры Dota 2
+
+- type: ttsVoice
+ name: Phantom Lancer
+ speaker: phantom_lancer_dota_2
+ id: phantom_lancer_dota_2
+ gender: male
+ description: Голос персонажа Phantom Lancer из игры Dota 2
+
+- type: ttsVoice
+ name: Pudge
+ speaker: pudge_dota_2
+ id: pudge_dota_2
+ gender: male
+ description: Голос персонажа Pudge из игры Dota 2
+
+- type: ttsVoice
+ name: Queen Of Pain
+ speaker: queen_of_pain_dota_2
+ id: queen_of_pain_dota_2
+ gender: female
+ description: Голос персонажа Queen Of Pain из игры Dota 2
+
+- type: ttsVoice
+ name: Razor
+ speaker: razor_dota_2
+ id: razor_dota_2
+ gender: male
+ description: Голос персонажа Razor из игры Dota 2
+
+- type: ttsVoice
+ name: Riki
+ speaker: riki_dota_2
+ id: riki_dota_2
+ gender: male
+ description: Голос персонажа Riki из игры Dota 2
+
+- type: ttsVoice
+ name: Rubick
+ speaker: rubick_dota_2
+ id: rubick_dota_2
+ gender: male
+ description: Голос персонажа Rubick из игры Dota 2
+
+- type: ttsVoice
+ name: Sand King
+ speaker: sand_king_dota_2
+ id: sand_king_dota_2
+ gender: male
+ description: Голос персонажа Sand King из игры Dota 2
+
+- type: ttsVoice
+ name: Shadow Fiend
+ speaker: shadow_fiend_dota_2
+ id: shadow_fiend_dota_2
+ gender: male
+ description: Голос персонажа Shadow Fiend из игры Dota 2
+
+- type: ttsVoice
+ name: Shadow Shaman
+ speaker: shadow_shaman_dota_2
+ id: shadow_shaman_dota_2
+ gender: male
+ description: Голос персонажа Shadow Shaman из игры Dota 2
+
+- type: ttsVoice
+ name: Skywrath Mage
+ speaker: skywrath_mage_dota_2
+ id: skywrath_mage_dota_2
+ gender: male
+ description: Голос персонажа Skywrath Mage из игры Dota 2
+
+- type: ttsVoice
+ name: Slardar
+ speaker: slardar_dota_2
+ id: slardar_dota_2
+ gender: male
+ description: Голос персонажа Slardar из игры Dota 2
+
+- type: ttsVoice
+ name: Slark
+ speaker: slark_dota_2
+ id: slark_dota_2
+ gender: male
+ description: Голос персонажа Slark из игры Dota 2
+
+- type: ttsVoice
+ name: Spirit Breaker
+ speaker: spirit_breaker_dota_2
+ id: spirit_breaker_dota_2
+ gender: male
+ description: Голос персонажа Spirit Breaker из игры Dota 2
+
+- type: ttsVoice
+ name: Storm Spirit
+ speaker: storm_spirit_dota_2
+ id: storm_spirit_dota_2
+ gender: male
+ description: Голос персонажа Storm Spirit из игры Dota 2
+
+- type: ttsVoice
+ name: Sven
+ speaker: sven_dota_2
+ id: sven_dota_2
+ gender: male
+ description: Голос персонажа Sven из игры Dota 2
+
+- type: ttsVoice
+ name: Templar Assassin
+ speaker: templar_assassin_dota_2
+ id: templar_assassin_dota_2
+ gender: female
+ description: Голос персонажа Templar Assassin из игры Dota 2
+
+- type: ttsVoice
+ name: Tidehunter
+ speaker: tidehunter_dota_2
+ id: tidehunter_dota_2
+ gender: male
+ description: Голос персонажа Tidehunter из игры Dota 2
+
+- type: ttsVoice
+ name: Treant Protector
+ speaker: treant_protector_dota_2
+ id: treant_protector_dota_2
+ gender: male
+ description: Голос персонажа Treant Protector из игры Dota 2
+
+- type: ttsVoice
+ name: Underlord
+ speaker: underlord_dota_2
+ id: underlord_dota_2
+ gender: male
+ description: Голос персонажа Underlord из игры Dota 2
+
+- type: ttsVoice
+ name: Ursa
+ speaker: ursa_dota_2
+ id: ursa_dota_2
+ gender: male
+ description: Голос персонажа Ursa из игры Dota 2
+
+- type: ttsVoice
+ name: Windranger
+ speaker: windranger_dota_2
+ id: windranger_dota_2
+ gender: female
+ description: Голос персонажа Windranger из игры Dota 2
+
+- type: ttsVoice
+ name: Winter Wyvern
+ speaker: winter_wyvern_dota_2
+ id: winter_wyvern_dota_2
+ gender: female
+ description: Голос персонажа Winter Wyvern из игры Dota 2
+
+- type: ttsVoice
+ name: Witch Doctor
+ speaker: witch_doctor_dota_2
+ id: witch_doctor_dota_2
+ gender: male
+ description: Голос персонажа Witch Doctor из игры Dota 2
+
+- type: ttsVoice
+ name: Wraith King
+ speaker: wraith_king_dota_2
+ id: wraith_king_dota_2
+ gender: male
+ description: Голос персонажа Wraith King из игры Dota 2
+
+- type: ttsVoice
+ name: Zeus
+ speaker: zeus_dota_2
+ id: zeus_dota_2
+ gender: male
+ description: Голос персонажа Zeus из игры Dota 2
+
+- type: ttsVoice
+ name: Мита
+ speaker: mita
+ id: mita
+ gender: female
+ description: Голос персонажа Мита из игры MiSide
+
+- type: ttsVoice
+ name: Сонная Мита
+ speaker: mitadream
+ id: mitadream
+ gender: female
+ description: Голос персонажа Сонная Мита из игры MiSide
+
+- type: ttsVoice
+ name: Мита Призрак
+ speaker: mitaghost
+ id: mitaghost
+ gender: female
+ description: Голос персонажа Мита Призрак из игры MiSide
+
+- type: ttsVoice
+ name: Добрая Мита
+ speaker: mitakind
+ id: mitakind
+ gender: female
+ description: Голос персонажа Добрая Мита из игры MiSide
+
+- type: ttsVoice
+ name: Коротковолосая Мита
+ speaker: mitashorthairs
+ id: mitashorthairs
+ gender: female
+ description: Голос персонажа Коротковолосая Мита из игры MiSide
+
+- type: ttsVoice
+ name: Культисты Хаоса
+ speaker: chaos_marines_cultists_dow
+ id: chaos_marines_cultists_dow
+ gender: male
+ description: "Голос юнита Культисты Хаоса из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Космодесантники Хаоса
+ speaker: chaos_marines_dow
+ id: chaos_marines_dow
+ gender: male
+ description: "Голос юнита Космодесантники Хаоса из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Лорд Хаоса
+ speaker: chaos_marines_lord_dow
+ id: chaos_marines_lord_dow
+ gender: male
+ description: "Голос юнита Лорд Хаоса из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Колдун Хаоса
+ speaker: chaos_marines_sorceror_dow
+ id: chaos_marines_sorceror_dow
+ gender: male
+ description: "Голос юнита Колдун Хаоса из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Архон Тёмных Эльдар
+ speaker: dark_eldar_archon_dow
+ id: dark_eldar_archon_dow
+ gender: male
+ description: "Голос юнита Архон Тёмных Эльдар из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Мандрагор Тёмных Эльдар
+ speaker: dark_eldar_mandrake_dow
+ id: dark_eldar_mandrake_dow
+ gender: male
+ description: "Голос юнита Мандрагор Тёмных Эльдар из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Воин Тёмных Эльдар
+ speaker: dark_eldar_warrior_dow
+ id: dark_eldar_warrior_dow
+ gender: male
+ description: "Голос юнита Воин Тёмных Эльдар из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Ведьма Тёмных Эльдар
+ speaker: dark_eldar_wych_dow
+ id: dark_eldar_wych_dow
+ gender: female
+ description: "Голос юнита Ведьма Тёмных Эльдар из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Стражи Эльдар
+ speaker: eldar_dow
+ id: eldar_dow
+ gender: female
+ description: "Голос юнита Стражи Эльдар из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Провидец Эльдар
+ speaker: eldar_farseer_dow
+ id: eldar_farseer_dow
+ gender: female
+ description: "Голос юнита Провидец Эльдар из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Огненный Дракон Эльдар
+ speaker: eldar_fire_dragon_dow
+ id: eldar_fire_dragon_dow
+ gender: female
+ description: "Голос юнита Огненный Дракон Эльдар из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Колдун Эльдар
+ speaker: eldar_warlock_dow
+ id: eldar_warlock_dow
+ gender: female
+ description: "Голос юнита Колдун Эльдар из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Ассасин Имперской Гвардии
+ speaker: guard_assassin_dow
+ id: guard_assassin_dow
+ gender: male
+ description: "Голос юнита Ассасин Имперской Гвардии из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Капитан Имперской Гвардии
+ speaker: guard_captain_dow
+ id: guard_captain_dow
+ gender: male
+ description: "Голос юнита Капитан Имперской Гвардии из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Комиссар Имперской Гвардии
+ speaker: guard_commissar_dow
+ id: guard_commissar_dow
+ gender: male
+ description: "Голос юнита Комиссар Имперской Гвардии из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Техножрец Имперской Гвардии
+ speaker: guard_enginseer_dow
+ id: guard_enginseer_dow
+ gender: male
+ description: "Голос юнита Техножрец Имперской Гвардии из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Сержант Имперской Гвардии
+ speaker: guard_guardsman_sergeant_dow
+ id: guard_guardsman_sergeant_dow
+ gender: male
+ description: "Голос юнита Сержант Имперской Гвардии из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Команда тяжёлого оружия Имперской Гвардии
+ speaker: guard_heavy_weapons_platoon_dow
+ id: guard_heavy_weapons_platoon_dow
+ gender: male
+ description: "Голос юнита Команда тяжёлого оружия Имперской Гвардии из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Касркин Имперской Гвардии
+ speaker: guard_karskin_dow
+ id: guard_karskin_dow
+ gender: male
+ description: "Голос юнита Касркин Имперской Гвардии из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Огрин Имперской Гвардии
+ speaker: guard_ogryn_dow
+ id: guard_ogryn_dow
+ gender: male
+ description: "Голос юнита Огрин Имперской Гвардии из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Священник Имперской Гвардии
+ speaker: guard_priest_dow
+ id: guard_priest_dow
+ gender: male
+ description: "Голос юнита Священник Имперской Гвардии из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Псайкер Имперской Гвардии
+ speaker: guard_psyker_dow
+ id: guard_psyker_dow
+ gender: male
+ description: "Голос юнита Псайкер Имперской Гвардии из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Бойзы Орков
+ speaker: orks_dow
+ id: orks_dow
+ gender: male
+ description: "Голос юнита Бойзы Орков из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Флэш Гитз и Нобы Орков
+ speaker: orks_flashgitznobs_dow
+ id: orks_flashgitznobs_dow
+ gender: male
+ description: "Голос юнитов Флэш Гитз и Нобы Орков из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Гретчины Орков
+ speaker: orks_grots_dow
+ id: orks_grots_dow
+ gender: male
+ description: "Голос юнита Гретчины Орков из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Варбосс Орков
+ speaker: orks_warboss_dow
+ id: orks_warboss_dow
+ gender: male
+ description: "Голос юнита Варбосс Орков из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Сестры Битвы
+ speaker: sisters_battle_sister_dow
+ id: sisters_battle_sister_dow
+ gender: female
+ description: "Голос юнита Сестры Битвы из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Миссионер Сестёр Битвы
+ speaker: sisters_missionary_dow
+ id: sisters_missionary_dow
+ gender: male
+ description: "Голос юнита Миссионер Сестёр Битвы из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Космодесантники 2
+ speaker: space_marines_2_dow
+ id: space_marines_2_dow
+ gender: male
+ description: "Голос юнита Космодесантники 2 из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Капеллан Космодесантников
+ speaker: space_marines_chaplain_dow
+ id: space_marines_chaplain_dow
+ gender: male
+ description: "Голос юнита Капеллан Космодесантников из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Космодесантники
+ speaker: space_marines_dow
+ id: space_marines_dow
+ gender: male
+ description: "Голос юнита Космодесантники из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Командир Сил Космодесантников
+ speaker: space_marines_force_commander_dow
+ id: space_marines_force_commander_dow
+ gender: male
+ description: "Голос юнита Командир Сил Космодесантников из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Библиарий Космодесантников
+ speaker: space_marines_librarian_dow
+ id: space_marines_librarian_dow
+ gender: male
+ description: "Голос юнита Библиарий Космодесантников из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Эфирный Тау
+ speaker: tau_ethereal_dow
+ id: tau_ethereal_dow
+ gender: male
+ description: "Голос юнита Эфирный Тау из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Крууты Тау
+ speaker: tau_kroot_dow
+ id: tau_kroot_dow
+ gender: male
+ description: "Голос юнита Крууты Тау из игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Инструкторша DOW
+ speaker: tutorial_f_dow
+ id: tutorial_f_dow
+ gender: female
+ description: "Женский голос инструктора из туториала игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Инструктор DOW
+ speaker: tutorial_m_dow
+ id: tutorial_m_dow
+ gender: male
+ description: "Мужской голос из туториала игры Warhammer 40,000: Dawn of War"
+
+- type: ttsVoice
+ name: Кингпин
+ speaker: kingpin
+ id: kingpin
+ gender: male
+ description: "Голос Кингпина из игры Kingpin: Life of Crime"