[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:
rhailrake
2023-04-27 08:03:44 +06:00
committed by Remuchi
parent e9b7473e1a
commit ea4f7595a2
47 changed files with 4162 additions and 55 deletions

View File

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

View File

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

View File

@@ -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'}" />

View File

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

View File

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

View File

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

View File

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

View 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);
}
}
}

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

View File

@@ -42,6 +42,7 @@ namespace Content.IntegrationTests.Tests.Preferences
"Charlie Charlieson",
"The biggest boy around.",
"Human",
"Eugene",
21,
Sex.Male,
Gender.Epicene,

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View 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"},
};
}

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

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

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

View File

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

View File

@@ -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()
{

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View 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