[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" /> <Label Name="AmbienceVolumeLabel" MinSize="48 0" Align="Right" />
<Control MinSize="4 0"/> <Control MinSize="4 0"/>
</BoxContainer> </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"> <BoxContainer Orientation="Horizontal" Margin="5 0 0 0">
<Label Text="{Loc 'ui-options-lobby-volume'}" HorizontalExpand="True" /> <Label Text="{Loc 'ui-options-lobby-volume'}" HorizontalExpand="True" />
<Control MinSize="8 0" /> <Control MinSize="8 0" />

View File

@@ -1,5 +1,6 @@
using Content.Client.Audio; using Content.Client.Audio;
using Content.Shared.CCVar; using Content.Shared.CCVar;
using Content.Shared.White;
using Robust.Client.Audio; using Robust.Client.Audio;
using Robust.Client.AutoGenerated; using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface; using Robust.Client.UserInterface;
@@ -36,6 +37,7 @@ namespace Content.Client.Options.UI.Tabs
AmbienceVolumeSlider.OnValueChanged += OnAmbienceVolumeSliderChanged; AmbienceVolumeSlider.OnValueChanged += OnAmbienceVolumeSliderChanged;
AmbienceSoundsSlider.OnValueChanged += OnAmbienceSoundsSliderChanged; AmbienceSoundsSlider.OnValueChanged += OnAmbienceSoundsSliderChanged;
LobbyVolumeSlider.OnValueChanged += OnLobbyVolumeSliderChanged; LobbyVolumeSlider.OnValueChanged += OnLobbyVolumeSliderChanged;
TtsVolumeSlider.OnValueChanged += OnTtsVolumeSliderChanged;
InterfaceVolumeSlider.OnValueChanged += OnInterfaceVolumeSliderChanged; InterfaceVolumeSlider.OnValueChanged += OnInterfaceVolumeSliderChanged;
LobbyMusicCheckBox.OnToggled += OnLobbyMusicCheckToggled; LobbyMusicCheckBox.OnToggled += OnLobbyMusicCheckToggled;
RestartSoundsCheckBox.OnToggled += OnRestartSoundsCheckToggled; RestartSoundsCheckBox.OnToggled += OnRestartSoundsCheckToggled;
@@ -58,9 +60,22 @@ namespace Content.Client.Options.UI.Tabs
AmbienceVolumeSlider.OnValueChanged -= OnAmbienceVolumeSliderChanged; AmbienceVolumeSlider.OnValueChanged -= OnAmbienceVolumeSliderChanged;
LobbyVolumeSlider.OnValueChanged -= OnLobbyVolumeSliderChanged; LobbyVolumeSlider.OnValueChanged -= OnLobbyVolumeSliderChanged;
InterfaceVolumeSlider.OnValueChanged -= OnInterfaceVolumeSliderChanged; InterfaceVolumeSlider.OnValueChanged -= OnInterfaceVolumeSliderChanged;
//WD-EDIT
TtsVolumeSlider.OnValueChanged -= OnTtsVolumeSliderChanged;
//WD-EDIT
base.Dispose(disposing); base.Dispose(disposing);
} }
//TTS-Start
private void OnTtsVolumeSliderChanged(Range obj)
{
UpdateChanges();
}
//TTS-End
private void OnLobbyVolumeSliderChanged(Range obj) private void OnLobbyVolumeSliderChanged(Range obj)
{ {
UpdateChanges(); UpdateChanges();
@@ -132,6 +147,11 @@ namespace Content.Client.Options.UI.Tabs
_cfg.SetCVar(CCVars.RestartSoundsEnabled, RestartSoundsCheckBox.Pressed); _cfg.SetCVar(CCVars.RestartSoundsEnabled, RestartSoundsCheckBox.Pressed);
_cfg.SetCVar(CCVars.EventMusicEnabled, EventMusicCheckBox.Pressed); _cfg.SetCVar(CCVars.EventMusicEnabled, EventMusicCheckBox.Pressed);
_cfg.SetCVar(CCVars.AdminSoundsEnabled, AdminSoundsCheckBox.Pressed); _cfg.SetCVar(CCVars.AdminSoundsEnabled, AdminSoundsCheckBox.Pressed);
//WD-EDIT
_cfg.SetCVar(WhiteCVars.TtsVolume, LV100ToDB(TtsVolumeSlider.Value));
//WD-EDIT
_cfg.SaveToFile(); _cfg.SaveToFile();
UpdateChanges(); UpdateChanges();
} }
@@ -156,9 +176,30 @@ namespace Content.Client.Options.UI.Tabs
RestartSoundsCheckBox.Pressed = _cfg.GetCVar(CCVars.RestartSoundsEnabled); RestartSoundsCheckBox.Pressed = _cfg.GetCVar(CCVars.RestartSoundsEnabled);
EventMusicCheckBox.Pressed = _cfg.GetCVar(CCVars.EventMusicEnabled); EventMusicCheckBox.Pressed = _cfg.GetCVar(CCVars.EventMusicEnabled);
AdminSoundsCheckBox.Pressed = _cfg.GetCVar(CCVars.AdminSoundsEnabled); AdminSoundsCheckBox.Pressed = _cfg.GetCVar(CCVars.AdminSoundsEnabled);
//WD-EDIT
TtsVolumeSlider.Value = DBToLV100(_cfg.GetCVar(WhiteCVars.TtsVolume));
//WD-EDIT
UpdateChanges(); 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() private void UpdateChanges()
{ {
// y'all need jesus. // y'all need jesus.
@@ -177,11 +218,18 @@ namespace Content.Client.Options.UI.Tabs
var isAmbientSoundsSame = (int)AmbienceSoundsSlider.Value == _cfg.GetCVar(CCVars.MaxAmbientSources); var isAmbientSoundsSame = (int)AmbienceSoundsSlider.Value == _cfg.GetCVar(CCVars.MaxAmbientSources);
var isLobbySame = LobbyMusicCheckBox.Pressed == _cfg.GetCVar(CCVars.LobbyMusicEnabled); 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 isRestartSoundsSame = RestartSoundsCheckBox.Pressed == _cfg.GetCVar(CCVars.RestartSoundsEnabled);
var isEventSame = EventMusicCheckBox.Pressed == _cfg.GetCVar(CCVars.EventMusicEnabled); var isEventSame = EventMusicCheckBox.Pressed == _cfg.GetCVar(CCVars.EventMusicEnabled);
var isAdminSoundsSame = AdminSoundsCheckBox.Pressed == _cfg.GetCVar(CCVars.AdminSoundsEnabled); var isAdminSoundsSame = AdminSoundsCheckBox.Pressed == _cfg.GetCVar(CCVars.AdminSoundsEnabled);
var isEverythingSame = isMasterVolumeSame && isMidiVolumeSame && isAmbientVolumeSame && isAmbientMusicVolumeSame && isAmbientSoundsSame && isLobbySame && isRestartSoundsSame && isEventSame var isEverythingSame = isMasterVolumeSame && isMidiVolumeSame && isAmbientVolumeSame && isAmbientMusicVolumeSame && isAmbientSoundsSame && isLobbySame && isRestartSoundsSame && isEventSame
&& isAdminSoundsSame && isLobbyVolumeSame && isInterfaceVolumeSame; && isAdminSoundsSame && isLobbyVolumeSame && isInterfaceVolumeSame;
isEverythingSame = isEverythingSame && isTtsVolumeSame; //WD-EDIT
ApplyButton.Disabled = isEverythingSame; ApplyButton.Disabled = isEverythingSame;
ResetButton.Disabled = isEverythingSame; ResetButton.Disabled = isEverythingSame;
MasterVolumeLabel.Text = MasterVolumeLabel.Text =
@@ -197,6 +245,11 @@ namespace Content.Client.Options.UI.Tabs
InterfaceVolumeLabel.Text = InterfaceVolumeLabel.Text =
Loc.GetString("ui-options-volume-percent", ("volume", InterfaceVolumeSlider.Value / 100)); Loc.GetString("ui-options-volume-percent", ("volume", InterfaceVolumeSlider.Value / 100));
AmbienceSoundsLabel.Text = ((int)AmbienceSoundsSlider.Value).ToString(); 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"/> <Control HorizontalExpand="True"/>
<OptionButton Name="CPronounsButton" HorizontalAlignment="Right" /> <OptionButton Name="CPronounsButton" HorizontalAlignment="Right" />
</BoxContainer> </BoxContainer>
<!-- TTS -->
<BoxContainer HorizontalExpand="True">
<Label Text="{Loc 'humanoid-profile-editor-voice-label'}" />
<Control HorizontalExpand="True"/>
<OptionButton Name="CVoiceButton" HorizontalAlignment="Right" />
</BoxContainer>
<!-- Show clothing --> <!-- Show clothing -->
<BoxContainer HorizontalExpand="True"> <BoxContainer HorizontalExpand="True">
<Label Text="{Loc 'humanoid-profile-editor-clothing'}" /> <Label Text="{Loc 'humanoid-profile-editor-clothing'}" />

View File

@@ -69,6 +69,11 @@ namespace Content.Client.Preferences.UI
private Button _saveButton => CSaveButton; private Button _saveButton => CSaveButton;
private OptionButton _sexButton => CSexButton; private OptionButton _sexButton => CSexButton;
private OptionButton _genderButton => CPronounsButton; private OptionButton _genderButton => CPronounsButton;
//WD-EDIT
private OptionButton _voiceButton => CVoiceButton;
//WD-EDIT
private Slider _skinColor => CSkin; private Slider _skinColor => CSkin;
private OptionButton _clothingButton => CClothingButton; private OptionButton _clothingButton => CClothingButton;
private OptionButton _backpackButton => CBackpackButton; private OptionButton _backpackButton => CBackpackButton;
@@ -172,6 +177,14 @@ namespace Content.Client.Preferences.UI
#endregion Gender #endregion Gender
//TTS-Start
#region Voice
InitializeVoice();
#endregion
//TTS-End
#region Species #region Species
_speciesList = prototypeManager.EnumeratePrototypes<SpeciesPrototype>().Where(o => o.RoundStart).ToList(); _speciesList = prototypeManager.EnumeratePrototypes<SpeciesPrototype>().Where(o => o.RoundStart).ToList();
@@ -748,9 +761,18 @@ namespace Content.Client.Preferences.UI
} }
UpdateGenderControls(); UpdateGenderControls();
CMarkings.SetSex(newSex); CMarkings.SetSex(newSex);
UpdateTTSVoicesControls(); //WD-EDIT
IsDirty = true; IsDirty = true;
} }
//WD-EDIT
private void SetVoice(string newVoice)
{
Profile = Profile?.WithVoice(newVoice);
IsDirty = true;
}
//WD-EDIT
private void SetGender(Gender newGender) private void SetGender(Gender newGender)
{ {
Profile = Profile?.WithGender(newGender); Profile = Profile?.WithGender(newGender);
@@ -1112,6 +1134,10 @@ namespace Content.Client.Preferences.UI
UpdateCMarkingsHair(); UpdateCMarkingsHair();
UpdateCMarkingsFacialHair(); UpdateCMarkingsFacialHair();
//WD-EDIT
UpdateTTSVoicesControls();
//WD-EDIT
_preferenceUnavailableButton.SelectId((int) Profile.PreferenceUnavailable); _preferenceUnavailableButton.SelectId((int) Profile.PreferenceUnavailable);
} }

View File

@@ -20,6 +20,7 @@ public sealed class VoiceMaskBoundUserInterface : BoundUserInterface
_window.OpenCentered(); _window.OpenCentered();
_window.OnNameChange += OnNameSelected; _window.OnNameChange += OnNameSelected;
_window.OnVoiceChange += (value) => SendMessage(new VoiceMaskChangeVoiceMessage(value));
_window.OnClose += Close; _window.OnClose += Close;
} }
@@ -35,7 +36,8 @@ public sealed class VoiceMaskBoundUserInterface : BoundUserInterface
return; return;
} }
_window.UpdateState(cast.Name); _window.UpdateState(cast.Name, cast.Voice);
} }
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)

View File

@@ -7,5 +7,9 @@
<LineEdit Name="NameSelector" HorizontalExpand="True" /> <LineEdit Name="NameSelector" HorizontalExpand="True" />
<Button Name="NameSelectorSet" Text="{Loc 'voice-mask-name-change-set'}" /> <Button Name="NameSelectorSet" Text="{Loc 'voice-mask-name-change-set'}" />
</BoxContainer> </BoxContainer>
<Label Text="{Loc 'voice-mask-voice-change-info'}" />
<BoxContainer Orientation="Horizontal">
<OptionButton Name="VoiceSelector" HorizontalExpand="True" />
</BoxContainer>
</BoxContainer> </BoxContainer>
</DefaultWindow> </DefaultWindow>

View File

@@ -1,6 +1,9 @@
using System.Linq;
using Content.Shared.White.TTS;
using Robust.Client.AutoGenerated; using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.CustomControls; using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML; using Robust.Client.UserInterface.XAML;
using Robust.Shared.Prototypes;
namespace Content.Client.VoiceMask; namespace Content.Client.VoiceMask;
@@ -9,6 +12,9 @@ public sealed partial class VoiceMaskNameChangeWindow : DefaultWindow
{ {
public Action<string>? OnNameChange; public Action<string>? OnNameChange;
private readonly List<TTSVoicePrototype> _voices; // TTS
public Action<string>? OnVoiceChange;
public VoiceMaskNameChangeWindow() public VoiceMaskNameChangeWindow()
{ {
RobustXamlLoader.Load(this); RobustXamlLoader.Load(this);
@@ -17,10 +23,31 @@ public sealed partial class VoiceMaskNameChangeWindow : DefaultWindow
{ {
OnNameChange!(NameSelector.Text); 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; 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", "Charlie Charlieson",
"The biggest boy around.", "The biggest boy around.",
"Human", "Human",
"Eugene",
21, 21,
Sex.Male, Sex.Male,
Gender.Epicene, 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") .HasColumnType("text")
.HasColumnName("species"); .HasColumnName("species");
b.Property<string>("Voice")
.IsRequired()
.HasColumnType("text")
.HasColumnName("voice");
b.HasKey("Id") b.HasKey("Id")
.HasName("PK_profile"); .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") .HasColumnType("TEXT")
.HasColumnName("species"); .HasColumnName("species");
b.Property<string>("Voice")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("voice");
b.HasKey("Id") b.HasKey("Id")
.HasName("PK_profile"); .HasName("PK_profile");

View File

@@ -324,6 +324,11 @@ namespace Content.Server.Database
public int Age { get; set; } public int Age { get; set; }
public string Sex { get; set; } = null!; public string Sex { get; set; } = null!;
public string Gender { 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!; public string Species { get; set; } = null!;
[Column(TypeName = "jsonb")] public JsonDocument? Markings { get; set; } = null!; [Column(TypeName = "jsonb")] public JsonDocument? Markings { get; set; } = null!;
public string HairName { 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); 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); RaiseLocalEvent(source, ev, true);
// To avoid logging any messages sent by entities that are not players, like vendors, cloning, etc. // 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))); _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); RaiseLocalEvent(source, ev, true);
if (hideLog) if (hideLog)
return; return;
@@ -940,6 +940,7 @@ public sealed class EntitySpokeEvent : EntityEventArgs
{ {
public readonly EntityUid Source; public readonly EntityUid Source;
public readonly string Message; public readonly string Message;
public readonly string OriginalMessage;
public readonly string? ObfuscatedMessage; // not null if this was a whisper public readonly string? ObfuscatedMessage; // not null if this was a whisper
/// <summary> /// <summary>
@@ -948,10 +949,11 @@ public sealed class EntitySpokeEvent : EntityEventArgs
/// </summary> /// </summary>
public RadioChannelPrototype? Channel; 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; Source = source;
Message = message; Message = message;
OriginalMessage = originalMessage;
Channel = channel; Channel = channel;
ObfuscatedMessage = obfuscatedMessage; ObfuscatedMessage = obfuscatedMessage;
} }

View File

@@ -190,6 +190,10 @@ namespace Content.Server.Database
if (Enum.TryParse<Gender>(profile.Gender, true, out var genderVal)) if (Enum.TryParse<Gender>(profile.Gender, true, out var genderVal))
gender = genderVal; gender = genderVal;
var voice = profile.Voice;
if (voice == string.Empty)
voice = SharedHumanoidAppearanceSystem.DefaultSexVoice[sex];
// ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract // ReSharper disable once ConditionalAccessQualifierIsNonNullableAccordingToAPIContract
var markingsRaw = profile.Markings?.Deserialize<List<string>>(); var markingsRaw = profile.Markings?.Deserialize<List<string>>();
@@ -210,6 +214,7 @@ namespace Content.Server.Database
profile.CharacterName, profile.CharacterName,
profile.FlavorText, profile.FlavorText,
profile.Species, profile.Species,
voice,
profile.Age, profile.Age,
sex, sex,
gender, gender,
@@ -260,6 +265,7 @@ namespace Content.Server.Database
profile.Markings = markings; profile.Markings = markings;
profile.Slot = slot; profile.Slot = slot;
profile.PreferenceUnavailable = (DbPreferenceUnavailableMode) humanoid.PreferenceUnavailable; profile.PreferenceUnavailable = (DbPreferenceUnavailableMode) humanoid.PreferenceUnavailable;
profile.Voice = humanoid.Voice;
profile.Jobs.Clear(); profile.Jobs.Clear();
profile.Jobs.AddRange( profile.Jobs.AddRange(

View File

@@ -32,6 +32,7 @@ using Robust.Shared.Utility;
using Content.Server.UtkaIntegration; using Content.Server.UtkaIntegration;
using Content.Server.White.JoinQueue; using Content.Server.White.JoinQueue;
using Content.Server.White.Sponsors; using Content.Server.White.Sponsors;
using Content.Server.White.TTS;
namespace Content.Server.Entry namespace Content.Server.Entry
{ {
@@ -109,6 +110,7 @@ namespace Content.Server.Entry
//WD-EDIT //WD-EDIT
IoCManager.Resolve<SponsorsManager>().Initialize(); IoCManager.Resolve<SponsorsManager>().Initialize();
IoCManager.Resolve<JoinQueueManager>().Initialize(); IoCManager.Resolve<JoinQueueManager>().Initialize();
IoCManager.Resolve<TTSManager>().Initialize();
//WD-EDIT //WD-EDIT
_voteManager.Initialize(); _voteManager.Initialize();
@@ -147,8 +149,6 @@ namespace Content.Server.Entry
IoCManager.Resolve<IGameMapManager>().Initialize(); IoCManager.Resolve<IGameMapManager>().Initialize();
IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<GameTicker>().PostInitialize(); IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<GameTicker>().PostInitialize();
IoCManager.Resolve<IBanManager>().Initialize(); IoCManager.Resolve<IBanManager>().Initialize();
IoCManager.Resolve<IBqlQueryManager>().DoAutoRegistrations();
IoCManager.Resolve<RoleBanManager>().Initialize();
//WD-EDIT //WD-EDIT
IoCManager.Resolve<UtkaTCPWrapper>().Initialize(); IoCManager.Resolve<UtkaTCPWrapper>().Initialize();

View File

@@ -1,3 +1,4 @@
using System.Linq;
using Content.Shared.Examine; using Content.Shared.Examine;
using Content.Shared.Humanoid; using Content.Shared.Humanoid;
using Content.Shared.Humanoid.Markings; using Content.Shared.Humanoid.Markings;
@@ -26,13 +27,98 @@ public sealed partial class HumanoidAppearanceSystem : SharedHumanoidAppearanceS
private void OnExamined(EntityUid uid, HumanoidAppearanceComponent component, ExaminedEvent args) 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 species = GetSpeciesRepresentation(component.Species).ToLower();
var age = GetAgeRepresentation(component.Species, component.Age); var age = GetAgeRepresentation(component.Species, component.Age);
args.PushText(Loc.GetString("humanoid-appearance-component-examine", ("user", identity), ("age", age), ("species", species))); 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 // this was done enough times that it only made sense to do it here
/// <summary> /// <summary>
@@ -67,6 +153,64 @@ public sealed partial class HumanoidAppearanceSystem : SharedHumanoidAppearanceS
Dirty(targetHumanoid); 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> /// <summary>
/// Removes a marking from a humanoid by ID. /// Removes a marking from a humanoid by ID.
/// </summary> /// </summary>
@@ -202,4 +346,13 @@ public sealed partial class HumanoidAppearanceSystem : SharedHumanoidAppearanceS
return Loc.GetString("identity-age-old"); 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.UtkaIntegration;
using Content.Server.White.JoinQueue; using Content.Server.White.JoinQueue;
using Content.Server.White.Sponsors; using Content.Server.White.Sponsors;
using Content.Server.White.TTS;
using Content.Shared.Administration;
using Content.Shared.Administration.Logs; using Content.Shared.Administration.Logs;
using Content.Shared.Administration.Managers; using Content.Shared.Administration.Managers;
using Content.Shared.Kitchen; using Content.Shared.Kitchen;
@@ -66,6 +68,7 @@ namespace Content.Server.IoC
IoCManager.Register<SponsorsManager>(); IoCManager.Register<SponsorsManager>();
IoCManager.Register<JoinQueueManager>(); IoCManager.Register<JoinQueueManager>();
IoCManager.Register<UtkaTCPWrapper>(); IoCManager.Register<UtkaTCPWrapper>();
IoCManager.Register<TTSManager>();
// WD-EDIT // WD-EDIT
} }
} }

View File

@@ -67,7 +67,7 @@ namespace Content.Server.Sandbox
//Logger.Info($"{placement.MsgChannel.UserName} spawned {placement.EntityTemplateName} on position {placement.EntityCoordinates}"); //Logger.Info($"{placement.MsgChannel.UserName} spawned {placement.EntityTemplateName} on position {placement.EntityCoordinates}");
var data = _playerManager.GetSessionByUserId(placement.MsgChannel.UserId); var data = _playerManager.GetSessionByUserId(placement.MsgChannel.UserId);
var playerUid = data.AttachedEntity.GetValueOrDefault(); var playerUid = data.AttachedEntity.GetValueOrDefault();
var coordinates = placement.EntityCoordinates; var coordinates = placement.NetCoordinates;
switch (placement.PlaceType) switch (placement.PlaceType)
{ {
case PlacementManagerMessage.StartPlacement: case PlacementManagerMessage.StartPlacement:
@@ -77,7 +77,7 @@ namespace Content.Server.Sandbox
case PlacementManagerMessage.RequestPlacement: case PlacementManagerMessage.RequestPlacement:
_adminLogger.Add(LogType.EntitySpawn, LogImpact.High, $"{placement.EntityTemplateName} was spawned by" + _adminLogger.Add(LogType.EntitySpawn, LogImpact.High, $"{placement.EntityTemplateName} was spawned by" +
$" {ToPrettyString(playerUid):player} at " + $" {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; break;
case PlacementManagerMessage.RequestEntRemove: case PlacementManagerMessage.RequestEntRemove:
_adminLogger.Add(LogType.EntitySpawn, LogImpact.High, $"{ToPrettyString(placement.EntityUid):entity} was deleted by {ToPrettyString(playerUid):player}"); _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? gameMap = null;
string? stationCode = 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) if (!_entMan.TryGetComponent(station, out AlertLevelComponent? alert) || stationCode != null)
{ {

View File

@@ -1,3 +1,5 @@
using Content.Shared.Humanoid;
namespace Content.Server.VoiceMask; namespace Content.Server.VoiceMask;
[RegisterComponent] [RegisterComponent]
@@ -6,4 +8,7 @@ public sealed partial class VoiceMaskComponent : Component
[ViewVariables(VVAccess.ReadWrite)] public bool Enabled = true; [ViewVariables(VVAccess.ReadWrite)] public bool Enabled = true;
[ViewVariables(VVAccess.ReadWrite)] public string VoiceName = "Unknown"; [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); var comp = EnsureComp<VoiceMaskComponent>(user);
comp.VoiceName = component.LastSetName; comp.VoiceName = component.LastSetName;
if (component.LastSetVoice != null)
comp.VoiceId = component.LastSetVoice;
_actions.AddAction(user, ref component.ActionEntity, component.Action, uid); _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<VoiceMaskerComponent, GotUnequippedEvent>(OnUnequip);
SubscribeLocalEvent<VoiceMaskSetNameEvent>(OnSetName); SubscribeLocalEvent<VoiceMaskSetNameEvent>(OnSetName);
// SubscribeLocalEvent<VoiceMaskerComponent, GetVerbsEvent<AlternativeVerb>>(GetVerbs); // SubscribeLocalEvent<VoiceMaskerComponent, GetVerbsEvent<AlternativeVerb>>(GetVerbs);
InitializeTTS();
} }
private void OnSetName(VoiceMaskSetNameEvent ev) private void OnSetName(VoiceMaskSetNameEvent ev)
@@ -93,6 +94,6 @@ public sealed partial class VoiceMaskSystem : EntitySystem
} }
if (_uiSystem.TryGetUi(owner, VoiceMaskUIKey.Key, out var bui)) 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 public sealed partial class VoiceMaskerComponent : Component
{ {
[ViewVariables(VVAccess.ReadWrite)] public string LastSetName = "Unknown"; [ViewVariables(VVAccess.ReadWrite)] public string LastSetName = "Unknown";
[ViewVariables(VVAccess.ReadWrite)] public string? LastSetVoice; // tts
[DataField("action", customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))] [DataField("action", customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>))]
public string Action = "ActionChangeVoiceMask"; 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.Markings;
using Content.Shared.Humanoid.Prototypes; using Content.Shared.Humanoid.Prototypes;
using Content.Shared.White.TTS;
using Robust.Shared.Enums; using Robust.Shared.Enums;
using Robust.Shared.GameStates; using Robust.Shared.GameStates;
using Robust.Shared.Prototypes; using Robust.Shared.Prototypes;
using Robust.Shared.Serialization; using Robust.Shared.Serialization;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
using Robust.Shared.Utility; using Robust.Shared.Utility;
namespace Content.Shared.Humanoid; namespace Content.Shared.Humanoid;
@@ -71,6 +73,9 @@ public sealed partial class HumanoidAppearanceComponent : Component
[DataField, AutoNetworkedField] [DataField, AutoNetworkedField]
public Color EyeColor = Color.Brown; public Color EyeColor = Color.Brown;
[DataField("voice", customTypeSerializer: typeof(PrototypeIdSerializer<TTSVoicePrototype>))]
public string Voice { get; set; } = SharedHumanoidAppearanceSystem.DefaultVoice;
/// <summary> /// <summary>
/// Hair color of this humanoid. Used to avoid looping through all markings /// Hair color of this humanoid. Used to avoid looping through all markings
/// </summary> /// </summary>

View File

@@ -25,6 +25,13 @@ public abstract class SharedHumanoidAppearanceSystem : EntitySystem
[ValidatePrototypeId<SpeciesPrototype>] [ValidatePrototypeId<SpeciesPrototype>]
public const string DefaultSpecies = "Human"; 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() public override void Initialize()
{ {

View File

@@ -8,6 +8,7 @@ using Content.Shared.Humanoid.Prototypes;
using Content.Shared.Random.Helpers; using Content.Shared.Random.Helpers;
using Content.Shared.Roles; using Content.Shared.Roles;
using Content.Shared.Traits; using Content.Shared.Traits;
using Content.Shared.White.TTS;
using Robust.Shared.Configuration; using Robust.Shared.Configuration;
using Robust.Shared.Enums; using Robust.Shared.Enums;
using Robust.Shared.Prototypes; using Robust.Shared.Prototypes;
@@ -37,6 +38,7 @@ namespace Content.Shared.Preferences
string species, string species,
int age, int age,
Sex sex, Sex sex,
string voice,
Gender gender, Gender gender,
HumanoidCharacterAppearance appearance, HumanoidCharacterAppearance appearance,
ClothingPreference clothing, ClothingPreference clothing,
@@ -49,6 +51,7 @@ namespace Content.Shared.Preferences
Name = name; Name = name;
FlavorText = flavortext; FlavorText = flavortext;
Species = species; Species = species;
Voice = voice;
Age = age; Age = age;
Sex = sex; Sex = sex;
Gender = gender; Gender = gender;
@@ -67,7 +70,7 @@ namespace Content.Shared.Preferences
Dictionary<string, JobPriority> jobPriorities, Dictionary<string, JobPriority> jobPriorities,
List<string> antagPreferences, List<string> antagPreferences,
List<string> traitPreferences) 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) jobPriorities, other.PreferenceUnavailable, antagPreferences, traitPreferences)
{ {
} }
@@ -82,6 +85,7 @@ namespace Content.Shared.Preferences
string name, string name,
string flavortext, string flavortext,
string species, string species,
string voice,
int age, int age,
Sex sex, Sex sex,
Gender gender, Gender gender,
@@ -92,7 +96,7 @@ namespace Content.Shared.Preferences
PreferenceUnavailableMode preferenceUnavailable, PreferenceUnavailableMode preferenceUnavailable,
IReadOnlyList<string> antagPreferences, IReadOnlyList<string> antagPreferences,
IReadOnlyList<string> traitPreferences) 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)) preferenceUnavailable, new List<string>(antagPreferences), new List<string>(traitPreferences))
{ {
} }
@@ -106,6 +110,7 @@ namespace Content.Shared.Preferences
"John Doe", "John Doe",
"", "",
SharedHumanoidAppearanceSystem.DefaultSpecies, SharedHumanoidAppearanceSystem.DefaultSpecies,
SharedHumanoidAppearanceSystem.DefaultVoice,
18, 18,
Sex.Male, Sex.Male,
Gender.Male, Gender.Male,
@@ -133,6 +138,7 @@ namespace Content.Shared.Preferences
"John Doe", "John Doe",
"", "",
species, species,
SharedHumanoidAppearanceSystem.DefaultVoice,
18, 18,
Sex.Male, Sex.Male,
Gender.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 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 gender = sex == Sex.Male ? Gender.Male : Gender.Female;
var name = GetName(species, gender); 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> new Dictionary<string, JobPriority>
{ {
{SharedGameTicker.FallbackOverflowJob, JobPriority.High}, {SharedGameTicker.FallbackOverflowJob, JobPriority.High},
@@ -191,6 +202,8 @@ namespace Content.Shared.Preferences
public string FlavorText { get; private set; } public string FlavorText { get; private set; }
public string Species { get; private set; } public string Species { get; private set; }
public string Voice { get; private set; }
[DataField("age")] [DataField("age")]
public int Age { get; private set; } public int Age { get; private set; }
@@ -211,6 +224,12 @@ namespace Content.Shared.Preferences
public IReadOnlyList<string> TraitPreferences => _traitPreferences; public IReadOnlyList<string> TraitPreferences => _traitPreferences;
public PreferenceUnavailableMode PreferenceUnavailable { get; private set; } public PreferenceUnavailableMode PreferenceUnavailable { get; private set; }
public HumanoidCharacterProfile WithVoice(string voice)
{
return new(this) { Voice = voice };
}
public HumanoidCharacterProfile WithName(string name) public HumanoidCharacterProfile WithName(string name)
{ {
return new(this) { Name = name }; return new(this) { Name = name };
@@ -500,6 +519,15 @@ namespace Content.Shared.Preferences
_traitPreferences.Clear(); _traitPreferences.Clear();
_traitPreferences.AddRange(traits); _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, // sorry this is kind of weird and duplicated,

View File

@@ -12,10 +12,12 @@ public enum VoiceMaskUIKey : byte
public sealed class VoiceMaskBuiState : BoundUserInterfaceState public sealed class VoiceMaskBuiState : BoundUserInterfaceState
{ {
public string Name { get; } public string Name { get; }
public string Voice { get; }
public VoiceMaskBuiState(string name) public VoiceMaskBuiState(string name, string voice)
{ {
Name = name; 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); 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 - key: enum.StrippingUiKey.Key
type: StrippableBoundUserInterface type: StrippableBoundUserInterface
- type: Puller - type: Puller
- type: Butcherable
butcheringType: Spike # TODO human.
spawned:
- id: FoodMeat
amount: 5
- type: TTS
- type: Speech - type: Speech
speechSounds: Alto speechSounds: Alto
- type: DamageForceSay - 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