[feat] TTS
# Conflicts: # Content.Client/Options/UI/Tabs/AudioTab.xaml.cs # Content.Client/Preferences/UI/HumanoidProfileEditor.xaml # Content.Client/Preferences/UI/HumanoidProfileEditor.xaml.cs # Content.Server/Database/ServerDbBase.cs # Content.Server/Entry/EntryPoint.cs # Content.Server/Humanoid/Systems/HumanoidAppearanceSystem.cs # Content.Server/IoC/ServerContentIoC.cs # Content.Server/VoiceMask/VoiceMaskSystem.cs # Resources/Prototypes/Entities/Mobs/Species/base.yml
This commit is contained in:
@@ -61,6 +61,19 @@
|
||||
<Label Name="AmbienceVolumeLabel" MinSize="48 0" Align="Right" />
|
||||
<Control MinSize="4 0"/>
|
||||
</BoxContainer>
|
||||
<BoxContainer Orientation="Horizontal" Margin="5 0 0 0">
|
||||
<Label Text="{Loc 'ui-options-tts-volume'}" HorizontalExpand="True" />
|
||||
<Control MinSize="8 0" />
|
||||
<Slider Name="TtsVolumeSlider"
|
||||
MinValue="0"
|
||||
MaxValue="200"
|
||||
HorizontalExpand="True"
|
||||
MinSize="80 0"
|
||||
Rounded="True" />
|
||||
<Control MinSize="8 0" />
|
||||
<Label Name="TtsVolumeLabel" MinSize="48 0" Align="Right" />
|
||||
<Control MinSize="4 0"/>
|
||||
</BoxContainer>
|
||||
<BoxContainer Orientation="Horizontal" Margin="5 0 0 0">
|
||||
<Label Text="{Loc 'ui-options-lobby-volume'}" HorizontalExpand="True" />
|
||||
<Control MinSize="8 0" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Content.Client.Audio;
|
||||
using Content.Shared.CCVar;
|
||||
using Content.Shared.White;
|
||||
using Robust.Client.Audio;
|
||||
using Robust.Client.AutoGenerated;
|
||||
using Robust.Client.UserInterface;
|
||||
@@ -36,6 +37,7 @@ namespace Content.Client.Options.UI.Tabs
|
||||
AmbienceVolumeSlider.OnValueChanged += OnAmbienceVolumeSliderChanged;
|
||||
AmbienceSoundsSlider.OnValueChanged += OnAmbienceSoundsSliderChanged;
|
||||
LobbyVolumeSlider.OnValueChanged += OnLobbyVolumeSliderChanged;
|
||||
TtsVolumeSlider.OnValueChanged += OnTtsVolumeSliderChanged;
|
||||
InterfaceVolumeSlider.OnValueChanged += OnInterfaceVolumeSliderChanged;
|
||||
LobbyMusicCheckBox.OnToggled += OnLobbyMusicCheckToggled;
|
||||
RestartSoundsCheckBox.OnToggled += OnRestartSoundsCheckToggled;
|
||||
@@ -58,9 +60,22 @@ namespace Content.Client.Options.UI.Tabs
|
||||
AmbienceVolumeSlider.OnValueChanged -= OnAmbienceVolumeSliderChanged;
|
||||
LobbyVolumeSlider.OnValueChanged -= OnLobbyVolumeSliderChanged;
|
||||
InterfaceVolumeSlider.OnValueChanged -= OnInterfaceVolumeSliderChanged;
|
||||
|
||||
//WD-EDIT
|
||||
TtsVolumeSlider.OnValueChanged -= OnTtsVolumeSliderChanged;
|
||||
//WD-EDIT
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
|
||||
//TTS-Start
|
||||
private void OnTtsVolumeSliderChanged(Range obj)
|
||||
{
|
||||
UpdateChanges();
|
||||
}
|
||||
//TTS-End
|
||||
|
||||
private void OnLobbyVolumeSliderChanged(Range obj)
|
||||
{
|
||||
UpdateChanges();
|
||||
@@ -132,6 +147,11 @@ namespace Content.Client.Options.UI.Tabs
|
||||
_cfg.SetCVar(CCVars.RestartSoundsEnabled, RestartSoundsCheckBox.Pressed);
|
||||
_cfg.SetCVar(CCVars.EventMusicEnabled, EventMusicCheckBox.Pressed);
|
||||
_cfg.SetCVar(CCVars.AdminSoundsEnabled, AdminSoundsCheckBox.Pressed);
|
||||
|
||||
//WD-EDIT
|
||||
_cfg.SetCVar(WhiteCVars.TtsVolume, LV100ToDB(TtsVolumeSlider.Value));
|
||||
//WD-EDIT
|
||||
|
||||
_cfg.SaveToFile();
|
||||
UpdateChanges();
|
||||
}
|
||||
@@ -156,9 +176,30 @@ namespace Content.Client.Options.UI.Tabs
|
||||
RestartSoundsCheckBox.Pressed = _cfg.GetCVar(CCVars.RestartSoundsEnabled);
|
||||
EventMusicCheckBox.Pressed = _cfg.GetCVar(CCVars.EventMusicEnabled);
|
||||
AdminSoundsCheckBox.Pressed = _cfg.GetCVar(CCVars.AdminSoundsEnabled);
|
||||
|
||||
//WD-EDIT
|
||||
TtsVolumeSlider.Value = DBToLV100(_cfg.GetCVar(WhiteCVars.TtsVolume));
|
||||
//WD-EDIT
|
||||
|
||||
|
||||
UpdateChanges();
|
||||
}
|
||||
|
||||
// Note: Rather than moving these functions somewhere, instead switch MidiManager to using linear units rather than dB
|
||||
// Do be sure to rename the setting though
|
||||
private float DBToLV100(float db, float multiplier = 1f)
|
||||
{
|
||||
var weh = (float) (Math.Pow(10, db / 10) * 100 / multiplier);
|
||||
return weh;
|
||||
}
|
||||
|
||||
private float LV100ToDB(float lv100, float multiplier = 1f)
|
||||
{
|
||||
// Saving negative infinity doesn't work, so use -10000000 instead (MidiManager does it)
|
||||
var weh = MathF.Max(-10000000, (float) (Math.Log(lv100 * multiplier / 100, 10) * 10));
|
||||
return weh;
|
||||
}
|
||||
|
||||
private void UpdateChanges()
|
||||
{
|
||||
// y'all need jesus.
|
||||
@@ -177,11 +218,18 @@ namespace Content.Client.Options.UI.Tabs
|
||||
|
||||
var isAmbientSoundsSame = (int)AmbienceSoundsSlider.Value == _cfg.GetCVar(CCVars.MaxAmbientSources);
|
||||
var isLobbySame = LobbyMusicCheckBox.Pressed == _cfg.GetCVar(CCVars.LobbyMusicEnabled);
|
||||
|
||||
//WD-EDIT
|
||||
var isTtsVolumeSame =
|
||||
Math.Abs(TtsVolumeSlider.Value - DBToLV100(_cfg.GetCVar(WhiteCVars.TtsVolume))) < 0.01f;
|
||||
//WD-EDIT
|
||||
|
||||
var isRestartSoundsSame = RestartSoundsCheckBox.Pressed == _cfg.GetCVar(CCVars.RestartSoundsEnabled);
|
||||
var isEventSame = EventMusicCheckBox.Pressed == _cfg.GetCVar(CCVars.EventMusicEnabled);
|
||||
var isAdminSoundsSame = AdminSoundsCheckBox.Pressed == _cfg.GetCVar(CCVars.AdminSoundsEnabled);
|
||||
var isEverythingSame = isMasterVolumeSame && isMidiVolumeSame && isAmbientVolumeSame && isAmbientMusicVolumeSame && isAmbientSoundsSame && isLobbySame && isRestartSoundsSame && isEventSame
|
||||
&& isAdminSoundsSame && isLobbyVolumeSame && isInterfaceVolumeSame;
|
||||
&& isAdminSoundsSame && isLobbyVolumeSame && isInterfaceVolumeSame;
|
||||
isEverythingSame = isEverythingSame && isTtsVolumeSame; //WD-EDIT
|
||||
ApplyButton.Disabled = isEverythingSame;
|
||||
ResetButton.Disabled = isEverythingSame;
|
||||
MasterVolumeLabel.Text =
|
||||
@@ -197,6 +245,11 @@ namespace Content.Client.Options.UI.Tabs
|
||||
InterfaceVolumeLabel.Text =
|
||||
Loc.GetString("ui-options-volume-percent", ("volume", InterfaceVolumeSlider.Value / 100));
|
||||
AmbienceSoundsLabel.Text = ((int)AmbienceSoundsSlider.Value).ToString();
|
||||
|
||||
//WD-EDIT
|
||||
TtsVolumeLabel.Text =
|
||||
Loc.GetString("ui-options-volume-percent", ("volume", TtsVolumeSlider.Value / 100));
|
||||
//WD-EDIT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,12 @@
|
||||
<Control HorizontalExpand="True"/>
|
||||
<OptionButton Name="CPronounsButton" HorizontalAlignment="Right" />
|
||||
</BoxContainer>
|
||||
<!-- TTS -->
|
||||
<BoxContainer HorizontalExpand="True">
|
||||
<Label Text="{Loc 'humanoid-profile-editor-voice-label'}" />
|
||||
<Control HorizontalExpand="True"/>
|
||||
<OptionButton Name="CVoiceButton" HorizontalAlignment="Right" />
|
||||
</BoxContainer>
|
||||
<!-- Show clothing -->
|
||||
<BoxContainer HorizontalExpand="True">
|
||||
<Label Text="{Loc 'humanoid-profile-editor-clothing'}" />
|
||||
|
||||
@@ -69,6 +69,11 @@ namespace Content.Client.Preferences.UI
|
||||
private Button _saveButton => CSaveButton;
|
||||
private OptionButton _sexButton => CSexButton;
|
||||
private OptionButton _genderButton => CPronounsButton;
|
||||
|
||||
//WD-EDIT
|
||||
private OptionButton _voiceButton => CVoiceButton;
|
||||
//WD-EDIT
|
||||
|
||||
private Slider _skinColor => CSkin;
|
||||
private OptionButton _clothingButton => CClothingButton;
|
||||
private OptionButton _backpackButton => CBackpackButton;
|
||||
@@ -172,6 +177,14 @@ namespace Content.Client.Preferences.UI
|
||||
|
||||
#endregion Gender
|
||||
|
||||
//TTS-Start
|
||||
#region Voice
|
||||
|
||||
InitializeVoice();
|
||||
|
||||
#endregion
|
||||
//TTS-End
|
||||
|
||||
#region Species
|
||||
|
||||
_speciesList = prototypeManager.EnumeratePrototypes<SpeciesPrototype>().Where(o => o.RoundStart).ToList();
|
||||
@@ -748,9 +761,18 @@ namespace Content.Client.Preferences.UI
|
||||
}
|
||||
UpdateGenderControls();
|
||||
CMarkings.SetSex(newSex);
|
||||
UpdateTTSVoicesControls(); //WD-EDIT
|
||||
IsDirty = true;
|
||||
}
|
||||
|
||||
//WD-EDIT
|
||||
private void SetVoice(string newVoice)
|
||||
{
|
||||
Profile = Profile?.WithVoice(newVoice);
|
||||
IsDirty = true;
|
||||
}
|
||||
//WD-EDIT
|
||||
|
||||
private void SetGender(Gender newGender)
|
||||
{
|
||||
Profile = Profile?.WithGender(newGender);
|
||||
@@ -1112,6 +1134,10 @@ namespace Content.Client.Preferences.UI
|
||||
UpdateCMarkingsHair();
|
||||
UpdateCMarkingsFacialHair();
|
||||
|
||||
//WD-EDIT
|
||||
UpdateTTSVoicesControls();
|
||||
//WD-EDIT
|
||||
|
||||
_preferenceUnavailableButton.SelectId((int) Profile.PreferenceUnavailable);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed class VoiceMaskBoundUserInterface : BoundUserInterface
|
||||
|
||||
_window.OpenCentered();
|
||||
_window.OnNameChange += OnNameSelected;
|
||||
_window.OnVoiceChange += (value) => SendMessage(new VoiceMaskChangeVoiceMessage(value));
|
||||
_window.OnClose += Close;
|
||||
}
|
||||
|
||||
@@ -35,7 +36,8 @@ public sealed class VoiceMaskBoundUserInterface : BoundUserInterface
|
||||
return;
|
||||
}
|
||||
|
||||
_window.UpdateState(cast.Name);
|
||||
_window.UpdateState(cast.Name, cast.Voice);
|
||||
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
|
||||
@@ -7,5 +7,9 @@
|
||||
<LineEdit Name="NameSelector" HorizontalExpand="True" />
|
||||
<Button Name="NameSelectorSet" Text="{Loc 'voice-mask-name-change-set'}" />
|
||||
</BoxContainer>
|
||||
<Label Text="{Loc 'voice-mask-voice-change-info'}" />
|
||||
<BoxContainer Orientation="Horizontal">
|
||||
<OptionButton Name="VoiceSelector" HorizontalExpand="True" />
|
||||
</BoxContainer>
|
||||
</BoxContainer>
|
||||
</DefaultWindow>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Linq;
|
||||
using Content.Shared.White.TTS;
|
||||
using Robust.Client.AutoGenerated;
|
||||
using Robust.Client.UserInterface.CustomControls;
|
||||
using Robust.Client.UserInterface.XAML;
|
||||
using Robust.Shared.Prototypes;
|
||||
|
||||
namespace Content.Client.VoiceMask;
|
||||
|
||||
@@ -9,6 +12,9 @@ public sealed partial class VoiceMaskNameChangeWindow : DefaultWindow
|
||||
{
|
||||
public Action<string>? OnNameChange;
|
||||
|
||||
private readonly List<TTSVoicePrototype> _voices; // TTS
|
||||
public Action<string>? OnVoiceChange;
|
||||
|
||||
public VoiceMaskNameChangeWindow()
|
||||
{
|
||||
RobustXamlLoader.Load(this);
|
||||
@@ -17,10 +23,31 @@ public sealed partial class VoiceMaskNameChangeWindow : DefaultWindow
|
||||
{
|
||||
OnNameChange!(NameSelector.Text);
|
||||
};
|
||||
|
||||
VoiceSelector.OnItemSelected += args =>
|
||||
{
|
||||
VoiceSelector.SelectId(args.Id);
|
||||
if (VoiceSelector.SelectedMetadata != null)
|
||||
OnVoiceChange!((string)VoiceSelector.SelectedMetadata);
|
||||
};
|
||||
_voices = IoCManager
|
||||
.Resolve<IPrototypeManager>()
|
||||
.EnumeratePrototypes<TTSVoicePrototype>()
|
||||
.Where(o => o.RoundStart)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateState(string name)
|
||||
public void UpdateState(string name, string voice)
|
||||
{
|
||||
NameSelector.Text = name;
|
||||
var voiceIdx = _voices.FindIndex(v => v.ID == voice);
|
||||
if (voiceIdx != -1)
|
||||
VoiceSelector.Select(voiceIdx);
|
||||
}
|
||||
}
|
||||
|
||||
61
Content.Client/White/TTS/HumanoidProfileEditor.TTS.cs
Normal file
61
Content.Client/White/TTS/HumanoidProfileEditor.TTS.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using System.Linq;
|
||||
using Content.Client.White.Sponsors;
|
||||
using Content.Shared.Preferences;
|
||||
using Content.Shared.White.TTS;
|
||||
|
||||
namespace Content.Client.Preferences.UI;
|
||||
|
||||
public sealed partial class HumanoidProfileEditor
|
||||
{
|
||||
private List<TTSVoicePrototype> _voiceList = default!;
|
||||
|
||||
private void InitializeVoice()
|
||||
{
|
||||
_voiceList = _prototypeManager.EnumeratePrototypes<TTSVoicePrototype>().Where(o => o.RoundStart).ToList();
|
||||
|
||||
_voiceButton.OnItemSelected += args =>
|
||||
{
|
||||
_voiceButton.SelectId(args.Id);
|
||||
SetVoice(_voiceList[args.Id].ID);
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
private void UpdateTTSVoicesControls()
|
||||
{
|
||||
if (Profile is null)
|
||||
return;
|
||||
|
||||
var sponsorsManager = IoCManager.Resolve<SponsorsManager>();
|
||||
|
||||
_voiceButton.Clear();
|
||||
|
||||
var firstVoiceChoiceId = 1;
|
||||
for (var i = 0; i < _voiceList.Count; i++)
|
||||
{
|
||||
var voice = _voiceList[i];
|
||||
if (!HumanoidCharacterProfile.CanHaveVoice(voice, Profile.Sex))
|
||||
continue;
|
||||
|
||||
var name = Loc.GetString(voice.Name);
|
||||
_voiceButton.AddItem(name, i);
|
||||
|
||||
if (firstVoiceChoiceId == 1)
|
||||
firstVoiceChoiceId = i;
|
||||
|
||||
if (voice.SponsorOnly &&
|
||||
sponsorsManager.TryGetInfo(out var sponsor) &&
|
||||
!sponsor.AllowedMarkings.Contains(voice.ID))
|
||||
{
|
||||
_voiceButton.SetItemDisabled(i, true);
|
||||
}
|
||||
}
|
||||
|
||||
var voiceChoiceId = _voiceList.FindIndex(x => x.ID == Profile.Voice);
|
||||
if (!_voiceButton.TrySelectId(voiceChoiceId) &&
|
||||
_voiceButton.TrySelectId(firstVoiceChoiceId))
|
||||
{
|
||||
SetVoice(_voiceList[firstVoiceChoiceId].ID);
|
||||
}
|
||||
}
|
||||
}
|
||||
213
Content.Client/White/TTS/TTSSystem.cs
Normal file
213
Content.Client/White/TTS/TTSSystem.cs
Normal file
@@ -0,0 +1,213 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Content.Shared.White.TTS;
|
||||
using Content.Shared.Physics;
|
||||
using Content.Shared.White;
|
||||
using Robust.Client.Audio;
|
||||
using Robust.Client.Graphics;
|
||||
using Robust.Shared.Audio.Sources;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Shared.Map;
|
||||
using Robust.Shared.Physics;
|
||||
using Robust.Shared.Physics.Systems;
|
||||
|
||||
namespace Content.Client.White.TTS;
|
||||
|
||||
/// <summary>
|
||||
/// Plays TTS audio in world
|
||||
/// </summary>
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public sealed class TTSSystem : EntitySystem
|
||||
{
|
||||
[Dependency] private readonly IAudioManager _audioSystem = default!;
|
||||
[Dependency] private readonly IEntityManager _entity = default!;
|
||||
[Dependency] private readonly IEyeManager _eye = default!;
|
||||
[Dependency] private readonly IConfigurationManager _cfg = default!;
|
||||
[Dependency] private readonly SharedPhysicsSystem _broadPhase = default!;
|
||||
|
||||
private ISawmill _sawmill = default!;
|
||||
private float _volume = 0.0f;
|
||||
|
||||
private readonly HashSet<AudioStream> _currentStreams = new();
|
||||
private readonly Dictionary<EntityUid, Queue<AudioStream>> _entityQueues = new();
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
_sawmill = Logger.GetSawmill("tts");
|
||||
_cfg.OnValueChanged(WhiteCVars.TtsVolume, OnTtsVolumeChanged, true);
|
||||
SubscribeNetworkEvent<PlayTTSEvent>(OnPlayTTS);
|
||||
}
|
||||
|
||||
public override void Shutdown()
|
||||
{
|
||||
base.Shutdown();
|
||||
_cfg.UnsubValueChanged(WhiteCVars.TtsVolume, OnTtsVolumeChanged);
|
||||
EndStreams();
|
||||
}
|
||||
|
||||
// Little bit of duplication logic from AudioSystem
|
||||
public override void FrameUpdate(float frameTime)
|
||||
{
|
||||
var streamToRemove = new HashSet<AudioStream>();
|
||||
|
||||
var ourPos = _eye.CurrentEye.Position.Position;
|
||||
foreach (var stream in _currentStreams)
|
||||
{
|
||||
if (!stream.Source.Playing ||
|
||||
!_entity.TryGetComponent<MetaDataComponent>(stream.Uid, out var meta) ||
|
||||
Deleted(stream.Uid, meta) ||
|
||||
!_entity.TryGetComponent<TransformComponent>(stream.Uid, out var xform))
|
||||
{
|
||||
stream.Source.Dispose();
|
||||
streamToRemove.Add(stream);
|
||||
continue;
|
||||
}
|
||||
|
||||
var mapPos = xform.MapPosition;
|
||||
if (mapPos.MapId != MapId.Nullspace)
|
||||
{
|
||||
stream.Source.Position = mapPos.Position;
|
||||
}
|
||||
|
||||
if (mapPos.MapId == _eye.CurrentMap)
|
||||
{
|
||||
var collisionMask = (int) CollisionGroup.Impassable;
|
||||
var sourceRelative = ourPos - mapPos.Position;
|
||||
var occlusion = 0f;
|
||||
if (sourceRelative.Length() > 0)
|
||||
{
|
||||
occlusion = _broadPhase.IntersectRayPenetration(mapPos.MapId,
|
||||
new CollisionRay(mapPos.Position, sourceRelative.Normalized(), collisionMask),
|
||||
sourceRelative.Length(), stream.Uid);
|
||||
}
|
||||
|
||||
stream.Source.Occlusion = occlusion;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var audioStream in streamToRemove)
|
||||
{
|
||||
_currentStreams.Remove(audioStream);
|
||||
ProcessEntityQueue(audioStream.Uid);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTtsVolumeChanged(float volume)
|
||||
{
|
||||
_volume = volume;
|
||||
}
|
||||
|
||||
private void OnPlayTTS(PlayTTSEvent ev)
|
||||
{
|
||||
if (!TryCreateAudioSource(ev.Data, out var source))
|
||||
return;
|
||||
|
||||
var stream = new AudioStream(GetEntity(ev.Uid), source);
|
||||
AddEntityStreamToQueue(stream);
|
||||
}
|
||||
|
||||
public void PlayCustomText(string text)
|
||||
{
|
||||
RaiseNetworkEvent(new RequestTTSEvent(text));
|
||||
}
|
||||
|
||||
private bool TryCreateAudioSource(byte[] data, [NotNullWhen(true)] out IAudioSource? source)
|
||||
{
|
||||
var dataStream = new MemoryStream(data) { Position = 0 };
|
||||
var audioStream = _audioSystem.LoadAudioWav(dataStream);
|
||||
source = _audioSystem.CreateAudioSource(audioStream);
|
||||
if (source == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
source.Volume = _volume;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void AddEntityStreamToQueue(AudioStream stream)
|
||||
{
|
||||
if (_entityQueues.TryGetValue(stream.Uid, out var queue))
|
||||
{
|
||||
queue.Enqueue(stream);
|
||||
}
|
||||
else
|
||||
{
|
||||
_entityQueues.Add(stream.Uid, new Queue<AudioStream>(new[] { stream }));
|
||||
|
||||
if (!IsEntityCurrentlyPlayStream(stream.Uid))
|
||||
ProcessEntityQueue(stream.Uid);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsEntityCurrentlyPlayStream(EntityUid uid)
|
||||
{
|
||||
return _currentStreams.Any(s => s.Uid == uid);
|
||||
}
|
||||
|
||||
private void ProcessEntityQueue(EntityUid uid)
|
||||
{
|
||||
if (TryTakeEntityStreamFromQueue(uid, out var stream))
|
||||
PlayEntity(stream);
|
||||
}
|
||||
|
||||
private bool TryTakeEntityStreamFromQueue(EntityUid uid, [NotNullWhen(true)] out AudioStream? stream)
|
||||
{
|
||||
if (_entityQueues.TryGetValue(uid, out var queue))
|
||||
{
|
||||
stream = queue.Dequeue();
|
||||
if (queue.Count == 0)
|
||||
_entityQueues.Remove(uid);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
stream = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void PlayEntity(AudioStream stream)
|
||||
{
|
||||
if (!_entity.TryGetComponent<TransformComponent>(stream.Uid, out var xform))
|
||||
return;
|
||||
|
||||
stream.Source.Position = xform.WorldPosition;
|
||||
stream.Source.StartPlaying();
|
||||
_currentStreams.Add(stream);
|
||||
}
|
||||
|
||||
public void StopAllStreams()
|
||||
{
|
||||
foreach (var stream in _currentStreams)
|
||||
{
|
||||
stream.Source.StopPlaying();
|
||||
}
|
||||
}
|
||||
|
||||
private void EndStreams()
|
||||
{
|
||||
foreach (var stream in _currentStreams)
|
||||
{
|
||||
stream.Source.StopPlaying();
|
||||
stream.Source.Dispose();
|
||||
}
|
||||
|
||||
_currentStreams.Clear();
|
||||
_entityQueues.Clear();
|
||||
}
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
private sealed class AudioStream
|
||||
{
|
||||
public EntityUid Uid { get; }
|
||||
|
||||
public IAudioSource Source { get; }
|
||||
|
||||
public AudioStream(EntityUid uid, IAudioSource source)
|
||||
{
|
||||
Uid = uid;
|
||||
Source = source;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ namespace Content.IntegrationTests.Tests.Preferences
|
||||
"Charlie Charlieson",
|
||||
"The biggest boy around.",
|
||||
"Human",
|
||||
"Eugene",
|
||||
21,
|
||||
Sex.Male,
|
||||
Gender.Epicene,
|
||||
|
||||
1338
Content.Server.Database/Migrations/Postgres/20221214230019_TTSVoice.Designer.cs
generated
Normal file
1338
Content.Server.Database/Migrations/Postgres/20221214230019_TTSVoice.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Content.Server.Database.Migrations.Postgres
|
||||
{
|
||||
public partial class TTSVoice : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "voice",
|
||||
table: "profile",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "voice",
|
||||
table: "profile");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -810,6 +810,11 @@ namespace Content.Server.Database.Migrations.Postgres
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("species");
|
||||
|
||||
b.Property<string>("Voice")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("voice");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("PK_profile");
|
||||
|
||||
|
||||
1272
Content.Server.Database/Migrations/Sqlite/20221214230014_TTSVoice.Designer.cs
generated
Normal file
1272
Content.Server.Database/Migrations/Sqlite/20221214230014_TTSVoice.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Content.Server.Database.Migrations.Sqlite
|
||||
{
|
||||
public partial class TTSVoice : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "voice",
|
||||
table: "profile",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "voice",
|
||||
table: "profile");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -764,6 +764,11 @@ namespace Content.Server.Database.Migrations.Sqlite
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("species");
|
||||
|
||||
b.Property<string>("Voice")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("voice");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("PK_profile");
|
||||
|
||||
|
||||
@@ -324,6 +324,11 @@ namespace Content.Server.Database
|
||||
public int Age { get; set; }
|
||||
public string Sex { get; set; } = null!;
|
||||
public string Gender { get; set; } = null!;
|
||||
|
||||
//WD-EDIT
|
||||
public string Voice { get; set; } = null!;
|
||||
//WD-EDIT
|
||||
|
||||
public string Species { get; set; } = null!;
|
||||
[Column(TypeName = "jsonb")] public JsonDocument? Markings { get; set; } = null!;
|
||||
public string HairName { get; set; } = null!;
|
||||
|
||||
@@ -418,7 +418,7 @@ public sealed partial class ChatSystem : SharedChatSystem
|
||||
|
||||
SendInVoiceRange(ChatChannel.Local, message, wrappedMessage, source, range);
|
||||
|
||||
var ev = new EntitySpokeEvent(source, message, null, null);
|
||||
var ev = new EntitySpokeEvent(source, message, originalMessage, null, null);
|
||||
RaiseLocalEvent(source, ev, true);
|
||||
|
||||
// To avoid logging any messages sent by entities that are not players, like vendors, cloning, etc.
|
||||
@@ -515,7 +515,7 @@ public sealed partial class ChatSystem : SharedChatSystem
|
||||
|
||||
_replay.RecordServerMessage(new ChatMessage(ChatChannel.Whisper, message, wrappedMessage, GetNetEntity(source), null, MessageRangeHideChatForReplay(range)));
|
||||
|
||||
var ev = new EntitySpokeEvent(source, message, channel, obfuscatedMessage);
|
||||
var ev = new EntitySpokeEvent(source, message, originalMessage, channel, obfuscatedMessage);
|
||||
RaiseLocalEvent(source, ev, true);
|
||||
if (hideLog)
|
||||
return;
|
||||
@@ -940,6 +940,7 @@ public sealed class EntitySpokeEvent : EntityEventArgs
|
||||
{
|
||||
public readonly EntityUid Source;
|
||||
public readonly string Message;
|
||||
public readonly string OriginalMessage;
|
||||
public readonly string? ObfuscatedMessage; // not null if this was a whisper
|
||||
|
||||
/// <summary>
|
||||
@@ -948,10 +949,11 @@ public sealed class EntitySpokeEvent : EntityEventArgs
|
||||
/// </summary>
|
||||
public RadioChannelPrototype? Channel;
|
||||
|
||||
public EntitySpokeEvent(EntityUid source, string message, RadioChannelPrototype? channel, string? obfuscatedMessage)
|
||||
public EntitySpokeEvent(EntityUid source, string message, string originalMessage, RadioChannelPrototype? channel, string? obfuscatedMessage)
|
||||
{
|
||||
Source = source;
|
||||
Message = message;
|
||||
OriginalMessage = originalMessage;
|
||||
Channel = channel;
|
||||
ObfuscatedMessage = obfuscatedMessage;
|
||||
}
|
||||
|
||||
@@ -190,6 +190,10 @@ namespace Content.Server.Database
|
||||
if (Enum.TryParse<Gender>(profile.Gender, true, out var genderVal))
|
||||
gender = genderVal;
|
||||
|
||||
var voice = profile.Voice;
|
||||
if (voice == string.Empty)
|
||||
voice = SharedHumanoidAppearanceSystem.DefaultSexVoice[sex];
|
||||
|
||||
// ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
|
||||
var markingsRaw = profile.Markings?.Deserialize<List<string>>();
|
||||
|
||||
@@ -210,6 +214,7 @@ namespace Content.Server.Database
|
||||
profile.CharacterName,
|
||||
profile.FlavorText,
|
||||
profile.Species,
|
||||
voice,
|
||||
profile.Age,
|
||||
sex,
|
||||
gender,
|
||||
@@ -260,6 +265,7 @@ namespace Content.Server.Database
|
||||
profile.Markings = markings;
|
||||
profile.Slot = slot;
|
||||
profile.PreferenceUnavailable = (DbPreferenceUnavailableMode) humanoid.PreferenceUnavailable;
|
||||
profile.Voice = humanoid.Voice;
|
||||
|
||||
profile.Jobs.Clear();
|
||||
profile.Jobs.AddRange(
|
||||
|
||||
@@ -32,6 +32,7 @@ using Robust.Shared.Utility;
|
||||
using Content.Server.UtkaIntegration;
|
||||
using Content.Server.White.JoinQueue;
|
||||
using Content.Server.White.Sponsors;
|
||||
using Content.Server.White.TTS;
|
||||
|
||||
namespace Content.Server.Entry
|
||||
{
|
||||
@@ -109,6 +110,7 @@ namespace Content.Server.Entry
|
||||
//WD-EDIT
|
||||
IoCManager.Resolve<SponsorsManager>().Initialize();
|
||||
IoCManager.Resolve<JoinQueueManager>().Initialize();
|
||||
IoCManager.Resolve<TTSManager>().Initialize();
|
||||
//WD-EDIT
|
||||
|
||||
_voteManager.Initialize();
|
||||
@@ -147,8 +149,6 @@ namespace Content.Server.Entry
|
||||
IoCManager.Resolve<IGameMapManager>().Initialize();
|
||||
IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<GameTicker>().PostInitialize();
|
||||
IoCManager.Resolve<IBanManager>().Initialize();
|
||||
IoCManager.Resolve<IBqlQueryManager>().DoAutoRegistrations();
|
||||
IoCManager.Resolve<RoleBanManager>().Initialize();
|
||||
|
||||
//WD-EDIT
|
||||
IoCManager.Resolve<UtkaTCPWrapper>().Initialize();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Linq;
|
||||
using Content.Shared.Examine;
|
||||
using Content.Shared.Humanoid;
|
||||
using Content.Shared.Humanoid.Markings;
|
||||
@@ -26,13 +27,98 @@ public sealed partial class HumanoidAppearanceSystem : SharedHumanoidAppearanceS
|
||||
|
||||
private void OnExamined(EntityUid uid, HumanoidAppearanceComponent component, ExaminedEvent args)
|
||||
{
|
||||
var identity = Identity.Entity(uid, EntityManager);
|
||||
var identity = Identity.Entity(component.Owner, EntityManager);
|
||||
var species = GetSpeciesRepresentation(component.Species).ToLower();
|
||||
var age = GetAgeRepresentation(component.Species, component.Age);
|
||||
|
||||
args.PushText(Loc.GetString("humanoid-appearance-component-examine", ("user", identity), ("age", age), ("species", species)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads a humanoid character profile directly onto this humanoid mob.
|
||||
/// </summary>
|
||||
/// <param name="uid">The mob's entity UID.</param>
|
||||
/// <param name="profile">The character profile to load.</param>
|
||||
/// <param name="humanoid">Humanoid component of the entity</param>
|
||||
public void LoadProfile(EntityUid uid, HumanoidCharacterProfile profile, HumanoidAppearanceComponent? humanoid = null)
|
||||
{
|
||||
if (!Resolve(uid, ref humanoid))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SetSpecies(uid, profile.Species, false, humanoid);
|
||||
SetSex(uid, profile.Sex, false, humanoid);
|
||||
humanoid.EyeColor = profile.Appearance.EyeColor;
|
||||
|
||||
SetSkinColor(uid, profile.Appearance.SkinColor, false);
|
||||
|
||||
humanoid.MarkingSet.Clear();
|
||||
|
||||
// Add markings that doesn't need coloring. We store them until we add all other markings that doesn't need it.
|
||||
var markingFColored = new Dictionary<Marking, MarkingPrototype>();
|
||||
foreach (var marking in profile.Appearance.Markings)
|
||||
{
|
||||
if (_markingManager.TryGetMarking(marking, out var prototype))
|
||||
{
|
||||
if (!prototype.ForcedColoring)
|
||||
{
|
||||
AddMarking(uid, marking.MarkingId, marking.MarkingColors, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
markingFColored.Add(marking, prototype);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hair/facial hair - this may eventually be deprecated.
|
||||
// We need to ensure hair before applying it or coloring can try depend on markings that can be invalid
|
||||
var hairColor = _markingManager.MustMatchSkin(profile.Species, HumanoidVisualLayers.Hair, out var hairAlpha, _prototypeManager)
|
||||
? profile.Appearance.SkinColor.WithAlpha(hairAlpha) : profile.Appearance.HairColor;
|
||||
var facialHairColor = _markingManager.MustMatchSkin(profile.Species, HumanoidVisualLayers.FacialHair, out var facialHairAlpha, _prototypeManager)
|
||||
? profile.Appearance.SkinColor.WithAlpha(facialHairAlpha) : profile.Appearance.FacialHairColor;
|
||||
|
||||
if (_markingManager.Markings.TryGetValue(profile.Appearance.HairStyleId, out var hairPrototype) &&
|
||||
_markingManager.CanBeApplied(profile.Species, profile.Sex, hairPrototype, _prototypeManager))
|
||||
{
|
||||
AddMarking(uid, profile.Appearance.HairStyleId, hairColor, false);
|
||||
}
|
||||
|
||||
if (_markingManager.Markings.TryGetValue(profile.Appearance.FacialHairStyleId, out var facialHairPrototype) &&
|
||||
_markingManager.CanBeApplied(profile.Species,profile.Sex, facialHairPrototype, _prototypeManager))
|
||||
{
|
||||
AddMarking(uid, profile.Appearance.FacialHairStyleId, facialHairColor, false);
|
||||
}
|
||||
|
||||
humanoid.MarkingSet.EnsureSpecies(profile.Species, profile.Appearance.SkinColor, _markingManager, _prototypeManager);
|
||||
|
||||
// Finally adding marking with forced colors
|
||||
foreach (var (marking, prototype) in markingFColored)
|
||||
{
|
||||
var markingColors = MarkingColoring.GetMarkingLayerColors(
|
||||
prototype,
|
||||
profile.Appearance.SkinColor,
|
||||
profile.Appearance.EyeColor,
|
||||
humanoid.MarkingSet
|
||||
);
|
||||
AddMarking(uid, marking.MarkingId, markingColors, false);
|
||||
}
|
||||
|
||||
EnsureDefaultMarkings(uid, humanoid);
|
||||
SetTTSVoice(uid, profile.Voice, humanoid);
|
||||
|
||||
humanoid.Gender = profile.Gender;
|
||||
if (TryComp<GrammarComponent>(uid, out var grammar))
|
||||
{
|
||||
grammar.Gender = profile.Gender;
|
||||
}
|
||||
|
||||
humanoid.Age = profile.Age;
|
||||
|
||||
Dirty(humanoid);
|
||||
}
|
||||
|
||||
// this was done enough times that it only made sense to do it here
|
||||
|
||||
/// <summary>
|
||||
@@ -67,6 +153,64 @@ public sealed partial class HumanoidAppearanceSystem : SharedHumanoidAppearanceS
|
||||
Dirty(targetHumanoid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a marking to this humanoid.
|
||||
/// </summary>
|
||||
/// <param name="uid">Humanoid mob's UID</param>
|
||||
/// <param name="marking">Marking ID to use</param>
|
||||
/// <param name="color">Color to apply to all marking layers of this marking</param>
|
||||
/// <param name="sync">Whether to immediately sync this marking or not</param>
|
||||
/// <param name="forced">If this marking was forced (ignores marking points)</param>
|
||||
/// <param name="humanoid">Humanoid component of the entity</param>
|
||||
public void AddMarking(EntityUid uid, string marking, Color? color = null, bool sync = true, bool forced = false, HumanoidAppearanceComponent? humanoid = null)
|
||||
{
|
||||
if (!Resolve(uid, ref humanoid)
|
||||
|| !_markingManager.Markings.TryGetValue(marking, out var prototype))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var markingObject = prototype.AsMarking();
|
||||
markingObject.Forced = forced;
|
||||
if (color != null)
|
||||
{
|
||||
for (var i = 0; i < prototype.Sprites.Count; i++)
|
||||
{
|
||||
markingObject.SetColor(i, color.Value);
|
||||
}
|
||||
}
|
||||
|
||||
humanoid.MarkingSet.AddBack(prototype.MarkingCategory, markingObject);
|
||||
|
||||
if (sync)
|
||||
Dirty(humanoid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="uid">Humanoid mob's UID</param>
|
||||
/// <param name="marking">Marking ID to use</param>
|
||||
/// <param name="colors">Colors to apply against this marking's set of sprites.</param>
|
||||
/// <param name="sync">Whether to immediately sync this marking or not</param>
|
||||
/// <param name="forced">If this marking was forced (ignores marking points)</param>
|
||||
/// <param name="humanoid">Humanoid component of the entity</param>
|
||||
public void AddMarking(EntityUid uid, string marking, IReadOnlyList<Color> colors, bool sync = true, bool forced = false, HumanoidAppearanceComponent? humanoid = null)
|
||||
{
|
||||
if (!Resolve(uid, ref humanoid)
|
||||
|| !_markingManager.Markings.TryGetValue(marking, out var prototype))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var markingObject = new Marking(marking, colors);
|
||||
markingObject.Forced = forced;
|
||||
humanoid.MarkingSet.AddBack(prototype.MarkingCategory, markingObject);
|
||||
|
||||
if (sync)
|
||||
Dirty(humanoid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a marking from a humanoid by ID.
|
||||
/// </summary>
|
||||
@@ -202,4 +346,13 @@ public sealed partial class HumanoidAppearanceSystem : SharedHumanoidAppearanceS
|
||||
|
||||
return Loc.GetString("identity-age-old");
|
||||
}
|
||||
|
||||
private void EnsureDefaultMarkings(EntityUid uid, HumanoidAppearanceComponent? humanoid)
|
||||
{
|
||||
if (!Resolve(uid, ref humanoid))
|
||||
{
|
||||
return;
|
||||
}
|
||||
humanoid.MarkingSet.EnsureDefault(humanoid.SkinColor, humanoid.EyeColor, _markingManager);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ using Content.Server.Worldgen.Tools;
|
||||
using Content.Server.UtkaIntegration;
|
||||
using Content.Server.White.JoinQueue;
|
||||
using Content.Server.White.Sponsors;
|
||||
using Content.Server.White.TTS;
|
||||
using Content.Shared.Administration;
|
||||
using Content.Shared.Administration.Logs;
|
||||
using Content.Shared.Administration.Managers;
|
||||
using Content.Shared.Kitchen;
|
||||
@@ -66,6 +68,7 @@ namespace Content.Server.IoC
|
||||
IoCManager.Register<SponsorsManager>();
|
||||
IoCManager.Register<JoinQueueManager>();
|
||||
IoCManager.Register<UtkaTCPWrapper>();
|
||||
IoCManager.Register<TTSManager>();
|
||||
// WD-EDIT
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ namespace Content.Server.Sandbox
|
||||
//Logger.Info($"{placement.MsgChannel.UserName} spawned {placement.EntityTemplateName} on position {placement.EntityCoordinates}");
|
||||
var data = _playerManager.GetSessionByUserId(placement.MsgChannel.UserId);
|
||||
var playerUid = data.AttachedEntity.GetValueOrDefault();
|
||||
var coordinates = placement.EntityCoordinates;
|
||||
var coordinates = placement.NetCoordinates;
|
||||
switch (placement.PlaceType)
|
||||
{
|
||||
case PlacementManagerMessage.StartPlacement:
|
||||
@@ -77,7 +77,7 @@ namespace Content.Server.Sandbox
|
||||
case PlacementManagerMessage.RequestPlacement:
|
||||
_adminLogger.Add(LogType.EntitySpawn, LogImpact.High, $"{placement.EntityTemplateName} was spawned by" +
|
||||
$" {ToPrettyString(playerUid):player} at " +
|
||||
$"{ToPrettyString(coordinates.EntityId):entity} X={coordinates.X}, Y={coordinates.Y}");
|
||||
$"{ToPrettyString(coordinates.NetEntity):entity} X={coordinates.X}, Y={coordinates.Y}");
|
||||
break;
|
||||
case PlacementManagerMessage.RequestEntRemove:
|
||||
_adminLogger.Add(LogType.EntitySpawn, LogImpact.High, $"{ToPrettyString(placement.EntityUid):entity} was deleted by {ToPrettyString(playerUid):player}");
|
||||
|
||||
@@ -54,7 +54,7 @@ public sealed class UtkaStatusCommand : IUtkaCommand
|
||||
|
||||
string? gameMap = null;
|
||||
string? stationCode = null;
|
||||
foreach (var station in _station.Stations)
|
||||
foreach (var station in _station.GetStations())
|
||||
{
|
||||
if (!_entMan.TryGetComponent(station, out AlertLevelComponent? alert) || stationCode != null)
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Content.Shared.Humanoid;
|
||||
|
||||
namespace Content.Server.VoiceMask;
|
||||
|
||||
[RegisterComponent]
|
||||
@@ -6,4 +8,7 @@ public sealed partial class VoiceMaskComponent : Component
|
||||
[ViewVariables(VVAccess.ReadWrite)] public bool Enabled = true;
|
||||
|
||||
[ViewVariables(VVAccess.ReadWrite)] public string VoiceName = "Unknown";
|
||||
|
||||
[ViewVariables(VVAccess.ReadWrite)] public string VoiceId = SharedHumanoidAppearanceSystem.DefaultVoice; //TTS
|
||||
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ public sealed partial class VoiceMaskSystem
|
||||
|
||||
var comp = EnsureComp<VoiceMaskComponent>(user);
|
||||
comp.VoiceName = component.LastSetName;
|
||||
if (component.LastSetVoice != null)
|
||||
comp.VoiceId = component.LastSetVoice;
|
||||
|
||||
_actions.AddAction(user, ref component.ActionEntity, component.Action, uid);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ public sealed partial class VoiceMaskSystem : EntitySystem
|
||||
SubscribeLocalEvent<VoiceMaskerComponent, GotUnequippedEvent>(OnUnequip);
|
||||
SubscribeLocalEvent<VoiceMaskSetNameEvent>(OnSetName);
|
||||
// SubscribeLocalEvent<VoiceMaskerComponent, GetVerbsEvent<AlternativeVerb>>(GetVerbs);
|
||||
InitializeTTS();
|
||||
}
|
||||
|
||||
private void OnSetName(VoiceMaskSetNameEvent ev)
|
||||
@@ -93,6 +94,6 @@ public sealed partial class VoiceMaskSystem : EntitySystem
|
||||
}
|
||||
|
||||
if (_uiSystem.TryGetUi(owner, VoiceMaskUIKey.Key, out var bui))
|
||||
_uiSystem.SetUiState(bui, new VoiceMaskBuiState(component.VoiceName));
|
||||
_uiSystem.SetUiState(bui, new VoiceMaskBuiState(component.VoiceName, component.VoiceId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Content.Server.VoiceMask;
|
||||
public sealed partial class VoiceMaskerComponent : Component
|
||||
{
|
||||
[ViewVariables(VVAccess.ReadWrite)] public string LastSetName = "Unknown";
|
||||
[ViewVariables(VVAccess.ReadWrite)] public string? LastSetVoice; // tts
|
||||
|
||||
[DataField("action", customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
|
||||
public string Action = "ActionChangeVoiceMask";
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
using System.Linq;
|
||||
using Content.Server.Administration;
|
||||
using Content.Shared.Administration;
|
||||
using Robust.Shared.Console;
|
||||
|
||||
namespace Content.Client.Commands;
|
||||
|
||||
[AdminCommand(AdminFlags.Debug)]
|
||||
public sealed class SetGlobalZoomCommand : IConsoleCommand
|
||||
{
|
||||
public string Command => "setglobalzoom";
|
||||
public string Description => "Sets the global zoom for all characters.";
|
||||
public string Help => "setglobalzoom <float>";
|
||||
public void Execute(IConsoleShell shell, string argStr, string[] args)
|
||||
{
|
||||
if(args.Length != 1)
|
||||
{
|
||||
shell.WriteLine("Invalid number of arguments.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!float.TryParse(args[0], out var zoom))
|
||||
{
|
||||
shell.WriteLine("Invalid zoom value.");
|
||||
return;
|
||||
}
|
||||
|
||||
var entityManager = IoCManager.Resolve<EntityManager>();
|
||||
var eyes = entityManager.GetAllComponents(typeof(SharedEyeComponent), true).Cast<SharedEyeComponent>().ToList();
|
||||
|
||||
foreach (var eye in eyes)
|
||||
{
|
||||
eye.Zoom = new Vector2(zoom, zoom);
|
||||
entityManager.Dirty(eye);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
17
Content.Server/White/TTS/HumanoidSystemTTS.cs
Normal file
17
Content.Server/White/TTS/HumanoidSystemTTS.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using Content.Server.White.TTS;
|
||||
using Content.Shared.Humanoid;
|
||||
|
||||
namespace Content.Server.Humanoid;
|
||||
|
||||
public sealed partial class HumanoidAppearanceSystem
|
||||
{
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public void SetTTSVoice(EntityUid uid, string voiceId, HumanoidAppearanceComponent humanoid)
|
||||
{
|
||||
if (!TryComp<TTSComponent>(uid, out var comp))
|
||||
return;
|
||||
|
||||
humanoid.Voice = voiceId;
|
||||
comp.VoicePrototypeId = voiceId;
|
||||
}
|
||||
}
|
||||
19
Content.Server/White/TTS/TTSComponent.cs
Normal file
19
Content.Server/White/TTS/TTSComponent.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using Content.Shared.White.TTS;
|
||||
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
|
||||
|
||||
namespace Content.Server.White.TTS;
|
||||
|
||||
/// <summary>
|
||||
/// Apply TTS for entity chat say messages
|
||||
/// </summary>
|
||||
[RegisterComponent]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public sealed partial class TTSComponent : Component
|
||||
{
|
||||
/// <summary>
|
||||
/// Prototype of used voice for TTS.
|
||||
/// </summary>
|
||||
[ViewVariables(VVAccess.ReadWrite)]
|
||||
[DataField("voice", customTypeSerializer:typeof(PrototypeIdSerializer<TTSVoicePrototype>))]
|
||||
public string VoicePrototypeId { get; set; } = string.Empty;
|
||||
}
|
||||
177
Content.Server/White/TTS/TTSManager.cs
Normal file
177
Content.Server/White/TTS/TTSManager.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using Content.Shared.CCVar;
|
||||
using Content.Shared.White;
|
||||
using Prometheus;
|
||||
using Robust.Shared.Configuration;
|
||||
|
||||
namespace Content.Server.White.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.");
|
||||
|
||||
private static readonly Gauge CachedCount = Metrics.CreateGauge(
|
||||
"tts_cached_count",
|
||||
"Amount of cached TTS audio.");
|
||||
|
||||
[Dependency] private readonly IConfigurationManager _cfg = default!;
|
||||
|
||||
private readonly HttpClient _httpClient = new();
|
||||
|
||||
private ISawmill _sawmill = default!;
|
||||
private readonly Dictionary<string, byte[]> _cache = new();
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
_sawmill = Logger.GetSawmill("tts");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates audio with passed text by API
|
||||
/// </summary>
|
||||
/// <param name="speaker">Identifier of speaker</param>
|
||||
/// <param name="text">SSML formatted text</param>
|
||||
/// <returns>OGG audio bytes</returns>
|
||||
/// <exception cref="Exception">Throws if url or token CCVar not set or http request failed</exception>
|
||||
public async Task<byte[]> ConvertTextToSpeech(string entityName, string speaker, string text)
|
||||
{
|
||||
var url = _cfg.GetCVar(WhiteCVars.TTSApiUrl);
|
||||
var maxCacheSize = _cfg.GetCVar(WhiteCVars.TTSMaxCacheSize);
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
throw new Exception("TTS Api url not specified");
|
||||
}
|
||||
|
||||
WantedCount.Inc();
|
||||
var cacheKey = GenerateCacheKey(speaker, text);
|
||||
if (_cache.TryGetValue(cacheKey, out var data))
|
||||
{
|
||||
ReusedCount.Inc();
|
||||
_sawmill.Debug($"Use cached sound for '{text}' speech by '{speaker}' speaker");
|
||||
return data;
|
||||
}
|
||||
|
||||
var body = new GenerateVoiceRequest
|
||||
{
|
||||
Text = text,
|
||||
Speaker = speaker,
|
||||
Ckey = entityName,
|
||||
};
|
||||
|
||||
var request = CreateRequestLink(url, body);
|
||||
|
||||
var reqTime = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
var response = await _httpClient.GetAsync(request, cts.Token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new Exception($"TTS request returned bad status code: {response.StatusCode}");
|
||||
}
|
||||
|
||||
var soundData = await response.Content.ReadAsByteArrayAsync(cts.Token);
|
||||
|
||||
if(_cache.Count > maxCacheSize)
|
||||
{
|
||||
_cache.Remove(_cache.Last().Key);
|
||||
}
|
||||
|
||||
_cache.Add(cacheKey, soundData);
|
||||
CachedCount.Inc();
|
||||
|
||||
_sawmill.Debug($"Generated new sound for '{text}' speech by '{speaker}' speaker ({soundData.Length} bytes)");
|
||||
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 sound for '{text}' speech by '{speaker}' speaker");
|
||||
throw new Exception("TTS request timeout");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
RequestTimings.WithLabels("Error").Observe((DateTime.UtcNow - reqTime).TotalSeconds);
|
||||
_sawmill.Error($"Failed of request generation new sound for '{text}' speech by '{speaker}' speaker\n{e}");
|
||||
throw new Exception("TTS request failed");
|
||||
}
|
||||
}
|
||||
|
||||
private static string CreateRequestLink(string url, GenerateVoiceRequest body)
|
||||
{
|
||||
var uriBuilder = new UriBuilder(url);
|
||||
var query = HttpUtility.ParseQueryString(uriBuilder.Query);
|
||||
query["ckey"] = body.Ckey;
|
||||
query["speaker"] = body.Speaker;
|
||||
query["text"] = body.Text;
|
||||
query["file"] = "1";
|
||||
uriBuilder.Query = query.ToString();
|
||||
return uriBuilder.ToString();
|
||||
}
|
||||
|
||||
public void ResetCache()
|
||||
{
|
||||
_cache.Clear();
|
||||
CachedCount.Set(0);
|
||||
}
|
||||
|
||||
private string GenerateCacheKey(string speaker, string text)
|
||||
{
|
||||
var key = $"{speaker}/{text}";
|
||||
byte[] keyData = Encoding.UTF8.GetBytes(key);
|
||||
var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var bytes = sha256.ComputeHash(keyData);
|
||||
return Convert.ToHexString(bytes);
|
||||
}
|
||||
|
||||
private record GenerateVoiceRequest
|
||||
{
|
||||
[JsonPropertyName("text")]
|
||||
public string Text { get; set; } = default!;
|
||||
|
||||
[JsonPropertyName("speaker")]
|
||||
public string Speaker { get; set; } = default!;
|
||||
|
||||
[JsonPropertyName("ckey")]
|
||||
public string Ckey { get; set; } = default!;
|
||||
}
|
||||
|
||||
private struct GenerateVoiceResponse
|
||||
{
|
||||
[JsonPropertyName("results")]
|
||||
public List<VoiceResult> Results { get; set; }
|
||||
|
||||
[JsonPropertyName("original_sha1")]
|
||||
public string Hash { get; set; }
|
||||
}
|
||||
|
||||
private struct VoiceResult
|
||||
{
|
||||
[JsonPropertyName("audio")]
|
||||
public string Audio { get; set; }
|
||||
}
|
||||
}
|
||||
29
Content.Server/White/TTS/TTSSystem.SSML.cs
Normal file
29
Content.Server/White/TTS/TTSSystem.SSML.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace Content.Server.White.TTS;
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public sealed partial class TTSSystem
|
||||
{
|
||||
private string ToSsmlText(string text, SpeechRate rate = SpeechRate.Medium)
|
||||
{
|
||||
return $"<speak><prosody rate=\"{SpeechRateMap[rate]}\">{text}</prosody></speak>";
|
||||
}
|
||||
|
||||
private enum SpeechRate : byte
|
||||
{
|
||||
VerySlow,
|
||||
Slow,
|
||||
Medium,
|
||||
Fast,
|
||||
VeryFast,
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyDictionary<SpeechRate, string> SpeechRateMap =
|
||||
new Dictionary<SpeechRate, string>()
|
||||
{
|
||||
{SpeechRate.VerySlow, "x-slow"},
|
||||
{SpeechRate.Slow, "slow"},
|
||||
{SpeechRate.Medium, "medium"},
|
||||
{SpeechRate.Fast, "fast"},
|
||||
{SpeechRate.VeryFast, "x-fast"},
|
||||
};
|
||||
}
|
||||
288
Content.Server/White/TTS/TTSSystem.Sanitize.cs
Normal file
288
Content.Server/White/TTS/TTSSystem.Sanitize.cs
Normal file
@@ -0,0 +1,288 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Content.Server.Chat.Systems;
|
||||
|
||||
namespace Content.Server.White.TTS;
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public sealed partial class TTSSystem
|
||||
{
|
||||
private void OnTransformSpeech(TransformSpeechEvent args)
|
||||
{
|
||||
if (!_isEnabled)
|
||||
return;
|
||||
args.Message = args.Message.Replace("+", "");
|
||||
}
|
||||
|
||||
private string Sanitize(string text)
|
||||
{
|
||||
text = text.Trim();
|
||||
text = Regex.Replace(text, @"Ё", "Е");
|
||||
text = Regex.Replace(text, @"[^a-zA-Zа-яА-ЯёЁ0-9,\-,+, ,?,!]", "");
|
||||
text = Regex.Replace(text, @"[a-zA-Z]", ReplaceLat2Cyr, RegexOptions.Multiline | RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, @"(?<![a-zA-Zа-яёА-ЯЁ])[a-zA-Zа-яёА-ЯЁ]+?(?![a-zA-Zа-яёА-ЯЁ])", ReplaceMatchedWord, RegexOptions.Multiline | RegexOptions.IgnoreCase);
|
||||
text = Regex.Replace(text, @"(?<=[1-90])(\.|,)(?=[1-90])", " целых ");
|
||||
text = Regex.Replace(text, @"\d+", ReplaceWord2Num);
|
||||
text = text.Trim();
|
||||
return text;
|
||||
}
|
||||
|
||||
private string ReplaceLat2Cyr(Match oneChar)
|
||||
{
|
||||
if (ReverseTranslit.TryGetValue(oneChar.Value.ToLower(), out var replace))
|
||||
return replace;
|
||||
return oneChar.Value;
|
||||
}
|
||||
|
||||
private string ReplaceMatchedWord(Match word)
|
||||
{
|
||||
if (WordReplacement.TryGetValue(word.Value.ToLower(), out var replace))
|
||||
return replace;
|
||||
return word.Value;
|
||||
}
|
||||
|
||||
private string ReplaceWord2Num(Match word)
|
||||
{
|
||||
if (!long.TryParse(word.Value, out var number))
|
||||
return word.Value;
|
||||
return NumberConverter.NumberToText(number);
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> WordReplacement =
|
||||
new Dictionary<string, string>()
|
||||
{
|
||||
{"нт", "Эн Тэ"},
|
||||
{"смо", "Эс Мэ О"},
|
||||
{"гп", "Гэ Пэ"},
|
||||
{"рд", "Эр Дэ"},
|
||||
{"гсб", "Гэ Эс Бэ"},
|
||||
{"гв", "Гэ Вэ"},
|
||||
{"нр", "Эн Эр"},
|
||||
{"срп", "Эс Эр Пэ"},
|
||||
{"цк", "Цэ Каа"},
|
||||
{"рнд", "Эр Эн Дэ"},
|
||||
{"сб", "Эс Бэ"},
|
||||
{"рцд", "Эр Цэ Дэ"},
|
||||
{"брпд", "Бэ Эр Пэ Дэ"},
|
||||
{"рпд", "Эр Пэ Дэ"},
|
||||
{"рпед", "Эр Пед"},
|
||||
{"тсф", "Тэ Эс Эф"},
|
||||
{"срт", "Эс Эр Тэ"},
|
||||
{"обр", "О Бэ Эр"},
|
||||
{"кпк", "Кэ Пэ Каа"},
|
||||
{"пда", "Пэ Дэ А"},
|
||||
{"id", "Ай Ди"},
|
||||
{"мщ", "Эм Ще"},
|
||||
{"вт", "Вэ Тэ"},
|
||||
{"ерп", "Йе Эр Пэ"},
|
||||
{"се", "Эс Йе"},
|
||||
{"апц", "А Пэ Цэ"},
|
||||
{"лкп", "Эл Ка Пэ"},
|
||||
{"см", "Эс Эм"},
|
||||
{"ека", "Йе Ка"},
|
||||
{"ка", "Кэ А"},
|
||||
{"бса", "Бэ Эс Аа"},
|
||||
{"тк", "Тэ Ка"},
|
||||
{"бфл", "Бэ Эф Эл"},
|
||||
{"бщ", "Бэ Щэ"},
|
||||
{"кк", "Кэ Ка"},
|
||||
{"ск", "Эс Ка"},
|
||||
{"зк", "Зэ Ка"},
|
||||
{"ерт", "Йе Эр Тэ"},
|
||||
{"вкд", "Вэ Ка Дэ"},
|
||||
{"нтр", "Эн Тэ Эр"},
|
||||
{"пнт", "Пэ Эн Тэ"},
|
||||
{"авд", "А Вэ Дэ"},
|
||||
{"пнв", "Пэ Эн Вэ"},
|
||||
{"ссд", "Эс Эс Дэ"},
|
||||
{"кпб", "Кэ Пэ Бэ"},
|
||||
{"сссп", "Эс Эс Эс Пэ"},
|
||||
{"крб", "Ка Эр Бэ"},
|
||||
{"бд", "Бэ Дэ"},
|
||||
{"сст", "Эс Эс Тэ"},
|
||||
{"скс", "Эс Ка Эс"},
|
||||
{"икн", "И Ка Эн"},
|
||||
{"нсс", "Эн Эс Эс"},
|
||||
{"емп", "Йе Эм Пэ"},
|
||||
{"бс", "Бэ Эс"},
|
||||
{"цкс", "Цэ Ка Эс"},
|
||||
{"срд", "Эс Эр Дэ"},
|
||||
{"жпс", "Джи Пи Эс"},
|
||||
{"gps", "Джи Пи Эс"},
|
||||
{"ннксс", "Эн Эн Ка Эс Эс"},
|
||||
{"ss", "Эс Эс"},
|
||||
{"сс", "Эс Эс"},
|
||||
{"тесла", "тэсла"},
|
||||
{"трейзен", "трэйзэн"},
|
||||
{"нанотрейзен", "нанотрэйзэн"},
|
||||
{"рпзд", "Эр Пэ Зэ Дэ"},
|
||||
{"кз", "Кэ Зэ"},
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string> ReverseTranslit =
|
||||
new Dictionary<string, string>()
|
||||
{
|
||||
{"a", "а"},
|
||||
{"b", "б"},
|
||||
{"v", "в"},
|
||||
{"g", "г"},
|
||||
{"d", "д"},
|
||||
{"e", "е"},
|
||||
{"je", "ё"},
|
||||
{"zh", "ж"},
|
||||
{"z", "з"},
|
||||
{"i", "и"},
|
||||
{"y", "й"},
|
||||
{"k", "к"},
|
||||
{"l", "л"},
|
||||
{"m", "м"},
|
||||
{"n", "н"},
|
||||
{"o", "о"},
|
||||
{"p", "п"},
|
||||
{"r", "р"},
|
||||
{"s", "с"},
|
||||
{"t", "т"},
|
||||
{"u", "у"},
|
||||
{"f", "ф"},
|
||||
{"h", "х"},
|
||||
{"c", "ц"},
|
||||
{"x", "кс"},
|
||||
{"ch", "ч"},
|
||||
{"sh", "ш"},
|
||||
{"jsh", "щ"},
|
||||
{"hh", "ъ"},
|
||||
{"ih", "ы"},
|
||||
{"jh", "ь"},
|
||||
{"eh", "э"},
|
||||
{"ju", "ю"},
|
||||
{"ja", "я"},
|
||||
};
|
||||
}
|
||||
|
||||
// Source: https://codelab.ru/s/csharp/digits2phrase
|
||||
public static class NumberConverter
|
||||
{
|
||||
private static readonly string[] Frac20Male =
|
||||
{
|
||||
"", "один", "два", "три", "четыре", "пять", "шесть",
|
||||
"семь", "восемь", "девять", "десять", "одиннадцать",
|
||||
"двенадцать", "тринадцать", "четырнадцать", "пятнадцать",
|
||||
"шестнадцать", "семнадцать", "восемнадцать", "девятнадцать"
|
||||
};
|
||||
|
||||
private static readonly string[] Frac20Female =
|
||||
{
|
||||
"", "одна", "две", "три", "четыре", "пять", "шесть",
|
||||
"семь", "восемь", "девять", "десять", "одиннадцать",
|
||||
"двенадцать", "тринадцать", "четырнадцать", "пятнадцать",
|
||||
"шестнадцать", "семнадцать", "восемнадцать", "девятнадцать"
|
||||
};
|
||||
|
||||
private static readonly string[] Hunds =
|
||||
{
|
||||
"", "сто", "двести", "триста", "четыреста",
|
||||
"пятьсот", "шестьсот", "семьсот", "восемьсот", "девятьсот"
|
||||
};
|
||||
|
||||
private static readonly string[] 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
132
Content.Server/White/TTS/TTSSystem.cs
Normal file
132
Content.Server/White/TTS/TTSSystem.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using System.Threading.Tasks;
|
||||
using Content.Server.Chat.Systems;
|
||||
using Content.Shared.White.TTS;
|
||||
using Content.Shared.GameTicking;
|
||||
using Content.Shared.White;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Shared.Player;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Random;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Content.Server.White.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 _random = default!;
|
||||
|
||||
private const int MaxMessageChars = 100 * 2; // same as SingleBubbleCharLimit * 2
|
||||
private bool _isEnabled = false;
|
||||
private string _apiUrl = string.Empty;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
_cfg.OnValueChanged(WhiteCVars.TTSEnabled, v => _isEnabled = v, true);
|
||||
_cfg.OnValueChanged(WhiteCVars.TTSApiUrl, url => _apiUrl = url, true);
|
||||
|
||||
SubscribeLocalEvent<TransformSpeechEvent>(OnTransformSpeech);
|
||||
SubscribeLocalEvent<TTSComponent, EntitySpokeEvent>(OnEntitySpoke);
|
||||
SubscribeLocalEvent<RoundRestartCleanupEvent>(OnRoundRestartCleanup);
|
||||
SubscribeNetworkEvent<RequestTTSEvent>(OnRequestTTS);
|
||||
}
|
||||
|
||||
private void OnRequestTTS(RequestTTSEvent ev)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private async void OnEntitySpoke(EntityUid uid, TTSComponent component, EntitySpokeEvent args)
|
||||
{
|
||||
if (!_isEnabled ||
|
||||
args.Message.Length > MaxMessageChars)
|
||||
return;
|
||||
|
||||
if (string.IsNullOrEmpty(_apiUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var voiceId = component.VoicePrototypeId;
|
||||
var voiceEv = new TransformSpeakerVoiceEvent(uid, voiceId);
|
||||
RaiseLocalEvent(uid, voiceEv);
|
||||
voiceId = voiceEv.VoiceId;
|
||||
|
||||
if (!_prototypeManager.TryIndex<TTSVoicePrototype>(voiceId, out var protoVoice))
|
||||
return;
|
||||
|
||||
var message = FormattedMessage.RemoveMarkup(args.Message);
|
||||
|
||||
var soundData = await GenerateTTS(uid, message, protoVoice.Speaker);
|
||||
if (soundData is null)
|
||||
return;
|
||||
var ttsEvent = new PlayTTSEvent(GetNetEntity(uid), soundData);
|
||||
|
||||
// Say
|
||||
if (args.ObfuscatedMessage is null)
|
||||
{
|
||||
RaiseNetworkEvent(ttsEvent, Filter.Pvs(uid));
|
||||
return;
|
||||
}
|
||||
|
||||
// Whisper
|
||||
var wList = new List<string>
|
||||
{
|
||||
"тсс",
|
||||
"псс",
|
||||
"ччч",
|
||||
"ссч",
|
||||
"сфч",
|
||||
"тст"
|
||||
};
|
||||
var chosenWhisperText = _random.Pick(wList);
|
||||
var obfSoundData = await GenerateTTS(uid, chosenWhisperText, protoVoice.Speaker);
|
||||
if (obfSoundData is null)
|
||||
return;
|
||||
var obfTtsEvent = new PlayTTSEvent(GetNetEntity(uid), obfSoundData);
|
||||
var xformQuery = GetEntityQuery<TransformComponent>();
|
||||
var sourcePos = _xforms.GetWorldPosition(xformQuery.GetComponent(uid), xformQuery);
|
||||
var receptions = Filter.Pvs(uid).Recipients;
|
||||
foreach (var session in receptions)
|
||||
{
|
||||
if (!session.AttachedEntity.HasValue)
|
||||
continue;
|
||||
var xform = xformQuery.GetComponent(session.AttachedEntity.Value);
|
||||
var distance = (sourcePos - _xforms.GetWorldPosition(xform, xformQuery)).LengthSquared();
|
||||
if (distance > (ChatSystem.VoiceRange * ChatSystem.VoiceRange))
|
||||
continue;
|
||||
|
||||
RaiseNetworkEvent(distance > ChatSystem.WhisperClearRange ? obfTtsEvent : ttsEvent, session);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRoundRestartCleanup(RoundRestartCleanupEvent ev)
|
||||
{
|
||||
_ttsManager.ResetCache();
|
||||
}
|
||||
|
||||
private async Task<byte[]?> GenerateTTS(EntityUid uid, string text, string speaker)
|
||||
{
|
||||
var textSanitized = Sanitize(text);
|
||||
if (textSanitized == "")
|
||||
return null;
|
||||
var metadata = Comp<MetaDataComponent>(uid);
|
||||
return await _ttsManager.ConvertTextToSpeech(metadata.EntityName, speaker, textSanitized);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TransformSpeakerVoiceEvent : EntityEventArgs
|
||||
{
|
||||
public EntityUid Sender;
|
||||
public string VoiceId;
|
||||
|
||||
public TransformSpeakerVoiceEvent(EntityUid sender, string voiceId)
|
||||
{
|
||||
Sender = sender;
|
||||
VoiceId = voiceId;
|
||||
}
|
||||
}
|
||||
42
Content.Server/White/TTS/VoiceMaskSystem.cs
Normal file
42
Content.Server/White/TTS/VoiceMaskSystem.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using Content.Server.White.TTS;
|
||||
using Content.Shared.VoiceMask;
|
||||
|
||||
namespace Content.Server.VoiceMask;
|
||||
|
||||
public partial class VoiceMaskSystem
|
||||
{
|
||||
private void InitializeTTS()
|
||||
{
|
||||
SubscribeLocalEvent<VoiceMaskComponent, TransformSpeakerVoiceEvent>(OnSpeakerVoiceTransform);
|
||||
SubscribeLocalEvent<VoiceMaskComponent, VoiceMaskChangeVoiceMessage>(OnChangeVoice);
|
||||
}
|
||||
|
||||
private void OnSpeakerVoiceTransform(EntityUid uid, VoiceMaskComponent component, TransformSpeakerVoiceEvent args)
|
||||
{
|
||||
if (component.Enabled)
|
||||
args.VoiceId = component.VoiceId;
|
||||
}
|
||||
|
||||
private void OnChangeVoice(EntityUid uid, VoiceMaskComponent component, VoiceMaskChangeVoiceMessage message)
|
||||
{
|
||||
component.VoiceId = message.Voice;
|
||||
|
||||
_popupSystem.PopupCursor(Loc.GetString("voice-mask-voice-popup-success"), message.Session);
|
||||
|
||||
TrySetLastKnownVoice(uid, message.Voice);
|
||||
|
||||
UpdateUI(uid, component);
|
||||
}
|
||||
|
||||
private void TrySetLastKnownVoice(EntityUid maskWearer, string? voiceId)
|
||||
{
|
||||
if (!HasComp<VoiceMaskComponent>(maskWearer)
|
||||
|| !_inventory.TryGetSlotEntity(maskWearer, MaskSlot, out var maskEntity)
|
||||
|| !TryComp<VoiceMaskerComponent>(maskEntity, out var maskComp))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
maskComp.LastSetVoice = voiceId;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
using Content.Shared.Humanoid.Markings;
|
||||
using Content.Shared.Humanoid.Prototypes;
|
||||
using Content.Shared.White.TTS;
|
||||
using Robust.Shared.Enums;
|
||||
using Robust.Shared.GameStates;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Serialization;
|
||||
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Content.Shared.Humanoid;
|
||||
@@ -71,6 +73,9 @@ public sealed partial class HumanoidAppearanceComponent : Component
|
||||
[DataField, AutoNetworkedField]
|
||||
public Color EyeColor = Color.Brown;
|
||||
|
||||
[DataField("voice", customTypeSerializer: typeof(PrototypeIdSerializer<TTSVoicePrototype>))]
|
||||
public string Voice { get; set; } = SharedHumanoidAppearanceSystem.DefaultVoice;
|
||||
|
||||
/// <summary>
|
||||
/// Hair color of this humanoid. Used to avoid looping through all markings
|
||||
/// </summary>
|
||||
|
||||
@@ -25,6 +25,13 @@ public abstract class SharedHumanoidAppearanceSystem : EntitySystem
|
||||
|
||||
[ValidatePrototypeId<SpeciesPrototype>]
|
||||
public const string DefaultSpecies = "Human";
|
||||
public const string DefaultVoice = "Eugene";
|
||||
public static readonly Dictionary<Sex, string> DefaultSexVoice = new()
|
||||
{
|
||||
{Sex.Male, "Eugene"},
|
||||
{Sex.Female, "Kseniya"},
|
||||
{Sex.Unsexed, "Xenia"},
|
||||
};
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ using Content.Shared.Humanoid.Prototypes;
|
||||
using Content.Shared.Random.Helpers;
|
||||
using Content.Shared.Roles;
|
||||
using Content.Shared.Traits;
|
||||
using Content.Shared.White.TTS;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Shared.Enums;
|
||||
using Robust.Shared.Prototypes;
|
||||
@@ -37,6 +38,7 @@ namespace Content.Shared.Preferences
|
||||
string species,
|
||||
int age,
|
||||
Sex sex,
|
||||
string voice,
|
||||
Gender gender,
|
||||
HumanoidCharacterAppearance appearance,
|
||||
ClothingPreference clothing,
|
||||
@@ -49,6 +51,7 @@ namespace Content.Shared.Preferences
|
||||
Name = name;
|
||||
FlavorText = flavortext;
|
||||
Species = species;
|
||||
Voice = voice;
|
||||
Age = age;
|
||||
Sex = sex;
|
||||
Gender = gender;
|
||||
@@ -67,7 +70,7 @@ namespace Content.Shared.Preferences
|
||||
Dictionary<string, JobPriority> jobPriorities,
|
||||
List<string> antagPreferences,
|
||||
List<string> traitPreferences)
|
||||
: this(other.Name, other.FlavorText, other.Species, other.Age, other.Sex, other.Gender, other.Appearance, other.Clothing, other.Backpack,
|
||||
: this(other.Name, other.FlavorText, other.Species, other.Voice, other.Age, other.Sex, other.Gender, other.Appearance, other.Clothing, other.Backpack,
|
||||
jobPriorities, other.PreferenceUnavailable, antagPreferences, traitPreferences)
|
||||
{
|
||||
}
|
||||
@@ -82,6 +85,7 @@ namespace Content.Shared.Preferences
|
||||
string name,
|
||||
string flavortext,
|
||||
string species,
|
||||
string voice,
|
||||
int age,
|
||||
Sex sex,
|
||||
Gender gender,
|
||||
@@ -92,7 +96,7 @@ namespace Content.Shared.Preferences
|
||||
PreferenceUnavailableMode preferenceUnavailable,
|
||||
IReadOnlyList<string> antagPreferences,
|
||||
IReadOnlyList<string> traitPreferences)
|
||||
: this(name, flavortext, species, age, sex, gender, appearance, clothing, backpack, new Dictionary<string, JobPriority>(jobPriorities),
|
||||
: this(name, flavortext, species, age, sex, voice, gender, appearance, clothing, backpack, new Dictionary<string, JobPriority>(jobPriorities),
|
||||
preferenceUnavailable, new List<string>(antagPreferences), new List<string>(traitPreferences))
|
||||
{
|
||||
}
|
||||
@@ -106,6 +110,7 @@ namespace Content.Shared.Preferences
|
||||
"John Doe",
|
||||
"",
|
||||
SharedHumanoidAppearanceSystem.DefaultSpecies,
|
||||
SharedHumanoidAppearanceSystem.DefaultVoice,
|
||||
18,
|
||||
Sex.Male,
|
||||
Gender.Male,
|
||||
@@ -133,6 +138,7 @@ namespace Content.Shared.Preferences
|
||||
"John Doe",
|
||||
"",
|
||||
species,
|
||||
SharedHumanoidAppearanceSystem.DefaultVoice,
|
||||
18,
|
||||
Sex.Male,
|
||||
Gender.Male,
|
||||
@@ -176,11 +182,16 @@ namespace Content.Shared.Preferences
|
||||
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
|
||||
}
|
||||
|
||||
var voiceId = random.Pick(prototypeManager
|
||||
.EnumeratePrototypes<TTSVoicePrototype>()
|
||||
.Where(o => CanHaveVoice(o, sex)).ToArray()
|
||||
).ID;
|
||||
|
||||
var gender = sex == Sex.Male ? Gender.Male : Gender.Female;
|
||||
|
||||
var name = GetName(species, gender);
|
||||
|
||||
return new HumanoidCharacterProfile(name, "", species, age, sex, gender, HumanoidCharacterAppearance.Random(species, sex), ClothingPreference.Jumpsuit, BackpackPreference.Backpack,
|
||||
return new HumanoidCharacterProfile(name, "", species, voiceId, age, sex, gender, HumanoidCharacterAppearance.Random(species, sex), ClothingPreference.Jumpsuit, BackpackPreference.Backpack,
|
||||
new Dictionary<string, JobPriority>
|
||||
{
|
||||
{SharedGameTicker.FallbackOverflowJob, JobPriority.High},
|
||||
@@ -191,6 +202,8 @@ namespace Content.Shared.Preferences
|
||||
public string FlavorText { get; private set; }
|
||||
public string Species { get; private set; }
|
||||
|
||||
public string Voice { get; private set; }
|
||||
|
||||
[DataField("age")]
|
||||
public int Age { get; private set; }
|
||||
|
||||
@@ -211,6 +224,12 @@ namespace Content.Shared.Preferences
|
||||
public IReadOnlyList<string> TraitPreferences => _traitPreferences;
|
||||
public PreferenceUnavailableMode PreferenceUnavailable { get; private set; }
|
||||
|
||||
|
||||
public HumanoidCharacterProfile WithVoice(string voice)
|
||||
{
|
||||
return new(this) { Voice = voice };
|
||||
}
|
||||
|
||||
public HumanoidCharacterProfile WithName(string name)
|
||||
{
|
||||
return new(this) { Name = name };
|
||||
@@ -500,6 +519,15 @@ namespace Content.Shared.Preferences
|
||||
|
||||
_traitPreferences.Clear();
|
||||
_traitPreferences.AddRange(traits);
|
||||
|
||||
prototypeManager.TryIndex<TTSVoicePrototype>(Voice, out var voice);
|
||||
if (voice is null || !CanHaveVoice(voice, Sex))
|
||||
Voice = SharedHumanoidAppearanceSystem.DefaultSexVoice[sex];
|
||||
}
|
||||
|
||||
public static bool CanHaveVoice(TTSVoicePrototype voice, Sex sex)
|
||||
{
|
||||
return voice.RoundStart && sex == Sex.Unsexed || (voice.Sex == sex || voice.Sex == Sex.Unsexed);
|
||||
}
|
||||
|
||||
// sorry this is kind of weird and duplicated,
|
||||
|
||||
@@ -12,10 +12,12 @@ public enum VoiceMaskUIKey : byte
|
||||
public sealed class VoiceMaskBuiState : BoundUserInterfaceState
|
||||
{
|
||||
public string Name { get; }
|
||||
public string Voice { get; }
|
||||
|
||||
public VoiceMaskBuiState(string name)
|
||||
public VoiceMaskBuiState(string name, string voice)
|
||||
{
|
||||
Name = name;
|
||||
Voice = voice;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
17
Content.Shared/White/TTS/PlayTTSEvent.cs
Normal file
17
Content.Shared/White/TTS/PlayTTSEvent.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using Robust.Shared.Serialization;
|
||||
|
||||
namespace Content.Shared.White.TTS;
|
||||
|
||||
[Serializable, NetSerializable]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public sealed class PlayTTSEvent : EntityEventArgs
|
||||
{
|
||||
public NetEntity Uid { get; }
|
||||
public byte[] Data { get; }
|
||||
|
||||
public PlayTTSEvent(NetEntity uid, byte[] data)
|
||||
{
|
||||
Uid = uid;
|
||||
Data = data;
|
||||
}
|
||||
}
|
||||
15
Content.Shared/White/TTS/RequestTTSEvent.cs
Normal file
15
Content.Shared/White/TTS/RequestTTSEvent.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using Robust.Shared.Serialization;
|
||||
|
||||
namespace Content.Shared.White.TTS;
|
||||
|
||||
[Serializable, NetSerializable]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public sealed class RequestTTSEvent : EntityEventArgs
|
||||
{
|
||||
public string Text { get; }
|
||||
|
||||
public RequestTTSEvent(string text)
|
||||
{
|
||||
Text = text;
|
||||
}
|
||||
}
|
||||
14
Content.Shared/White/TTS/SharedVoiceMaskSystem.cs
Normal file
14
Content.Shared/White/TTS/SharedVoiceMaskSystem.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using Robust.Shared.Serialization;
|
||||
|
||||
namespace Content.Shared.VoiceMask;
|
||||
|
||||
[Serializable, NetSerializable]
|
||||
public sealed class VoiceMaskChangeVoiceMessage : BoundUserInterfaceMessage
|
||||
{
|
||||
public string Voice { get; }
|
||||
|
||||
public VoiceMaskChangeVoiceMessage(string voice)
|
||||
{
|
||||
Voice = voice;
|
||||
}
|
||||
}
|
||||
34
Content.Shared/White/TTS/TTSVoicePrototype.cs
Normal file
34
Content.Shared/White/TTS/TTSVoicePrototype.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using Content.Shared.Humanoid;
|
||||
using Robust.Shared.Prototypes;
|
||||
|
||||
namespace Content.Shared.White.TTS;
|
||||
|
||||
/// <summary>
|
||||
/// Prototype represent available TTS voices
|
||||
/// </summary>
|
||||
[Prototype("ttsVoice")]
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public sealed class TTSVoicePrototype : IPrototype
|
||||
{
|
||||
[IdDataFieldAttribute]
|
||||
public string ID { get; } = default!;
|
||||
|
||||
[DataField("name")]
|
||||
public string Name { get; } = string.Empty;
|
||||
|
||||
[DataField("sex", required: true)]
|
||||
public Sex Sex { get; } = default!;
|
||||
|
||||
[ViewVariables(VVAccess.ReadWrite)]
|
||||
[DataField("speaker", required: true)]
|
||||
public string Speaker { get; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the species is available "at round start" (In the character editor)
|
||||
/// </summary>
|
||||
[DataField("roundStart")]
|
||||
public bool RoundStart { get; } = true;
|
||||
|
||||
[DataField("sponsorOnly")]
|
||||
public bool SponsorOnly { get; } = false;
|
||||
}
|
||||
@@ -62,4 +62,32 @@ public sealed class WhiteCVars
|
||||
|
||||
public static readonly CVarDef<string> UtkaSocketKey = CVarDef.Create("utka.socket_key", "ass", CVar.SERVERONLY | CVar.CONFIDENTIAL);
|
||||
|
||||
/**
|
||||
* TTS (Text-To-Speech)
|
||||
*/
|
||||
|
||||
/// <summary>
|
||||
/// URL of the TTS server API.
|
||||
/// </summary>
|
||||
public static readonly CVarDef<bool> TTSEnabled =
|
||||
CVarDef.Create("tts.enabled", true, CVar.SERVERONLY);
|
||||
|
||||
/// <summary>
|
||||
/// URL of the TTS server API.
|
||||
/// </summary>
|
||||
public static readonly CVarDef<string> TTSApiUrl =
|
||||
CVarDef.Create("tts.api_url", "http://127.0.0.1:2386", CVar.SERVERONLY);
|
||||
|
||||
/// <summary>
|
||||
/// TTS Volume
|
||||
/// </summary>
|
||||
public static readonly CVarDef<float> TtsVolume =
|
||||
CVarDef.Create("tts.volume", 0f, CVar.CLIENTONLY | CVar.ARCHIVE);
|
||||
|
||||
/// <summary>
|
||||
/// TTS Cache
|
||||
/// </summary>
|
||||
public static readonly CVarDef<int> TTSMaxCacheSize =
|
||||
CVarDef.Create("tts.max_cash_size", 200, CVar.SERVERONLY | CVar.ARCHIVE);
|
||||
|
||||
}
|
||||
|
||||
@@ -197,6 +197,12 @@
|
||||
- key: enum.StrippingUiKey.Key
|
||||
type: StrippableBoundUserInterface
|
||||
- type: Puller
|
||||
- type: Butcherable
|
||||
butcheringType: Spike # TODO human.
|
||||
spawned:
|
||||
- id: FoodMeat
|
||||
amount: 5
|
||||
- type: TTS
|
||||
- type: Speech
|
||||
speechSounds: Alto
|
||||
- type: DamageForceSay
|
||||
|
||||
29
Resources/Prototypes/White/tts-voices.yml
Normal file
29
Resources/Prototypes/White/tts-voices.yml
Normal file
@@ -0,0 +1,29 @@
|
||||
- type: ttsVoice
|
||||
id: Aidar
|
||||
name: tts-voice-name-aidar
|
||||
sex: Male
|
||||
speaker: aidar
|
||||
|
||||
- type: ttsVoice
|
||||
id: Eugene
|
||||
name: tts-voice-name-eugene
|
||||
sex: Male
|
||||
speaker: eugene
|
||||
|
||||
- type: ttsVoice
|
||||
id: Baya
|
||||
name: tts-voice-name-baya
|
||||
sex: Female
|
||||
speaker: baya
|
||||
|
||||
- type: ttsVoice
|
||||
id: Kseniya
|
||||
name: tts-voice-name-kseniya
|
||||
sex: Female
|
||||
speaker: kseniya
|
||||
|
||||
- type: ttsVoice
|
||||
id: Xenia
|
||||
name: tts-voice-name-xenia
|
||||
sex: Female
|
||||
speaker: xenia
|
||||
Reference in New Issue
Block a user