Implement cyborg TTS voice changing with robot effect and sponsor voice validation (#2936)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: VigersRay <60344369+VigersRay@users.noreply.github.com> Co-authored-by: Vigers Ray <vigersray@gmail.com>
This commit is contained in:
parent
5d3d6a484c
commit
90870e609a
15 changed files with 508 additions and 90 deletions
53
Content.Client/Silicons/Borgs/BorgVoiceBoundUserInterface.cs
Normal file
53
Content.Client/Silicons/Borgs/BorgVoiceBoundUserInterface.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
using Content.Client._Sunrise.TTS;
|
||||
using Content.Shared._Sunrise.TTS;
|
||||
using Robust.Client.GameObjects;
|
||||
using Robust.Client.UserInterface;
|
||||
|
||||
namespace Content.Client.Silicons.Borgs;
|
||||
|
||||
public sealed class BorgVoiceBoundUserInterface : BoundUserInterface
|
||||
{
|
||||
private BorgVoiceWindow? _window;
|
||||
|
||||
public BorgVoiceBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
|
||||
{
|
||||
IoCManager.InjectDependencies(this);
|
||||
}
|
||||
|
||||
protected override void Open()
|
||||
{
|
||||
base.Open();
|
||||
|
||||
_window = this.CreateWindow<BorgVoiceWindow>();
|
||||
_window.OnVoiceSelected += OnVoiceSelected;
|
||||
_window.OnVoicePreview += OnVoicePreview;
|
||||
}
|
||||
|
||||
protected override void UpdateState(BoundUserInterfaceState state)
|
||||
{
|
||||
base.UpdateState(state);
|
||||
|
||||
if (state is not BorgVoiceChangeState borgState || _window == null)
|
||||
return;
|
||||
|
||||
_window.UpdateState(borgState);
|
||||
}
|
||||
|
||||
private void OnVoiceSelected(string voiceId)
|
||||
{
|
||||
SendMessage(new BorgVoiceChangeMessage(voiceId));
|
||||
}
|
||||
|
||||
private void OnVoicePreview(string voiceId)
|
||||
{
|
||||
EntMan.System<TTSSystem>().RequestPreviewTts(voiceId);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
if (!disposing) return;
|
||||
|
||||
_window?.Dispose();
|
||||
}
|
||||
}
|
||||
85
Content.Client/Silicons/Borgs/BorgVoiceWindow.cs
Normal file
85
Content.Client/Silicons/Borgs/BorgVoiceWindow.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
using System.Linq;
|
||||
using Content.Shared._Sunrise.TTS;
|
||||
using Content.Shared.Humanoid;
|
||||
using Robust.Client.AutoGenerated;
|
||||
using Robust.Client.UserInterface.Controls;
|
||||
using Robust.Client.UserInterface.CustomControls;
|
||||
using Robust.Client.UserInterface.XAML;
|
||||
using Robust.Shared.Localization;
|
||||
using Robust.Shared.Prototypes;
|
||||
|
||||
namespace Content.Client.Silicons.Borgs;
|
||||
|
||||
[GenerateTypedNameReferences]
|
||||
public sealed partial class BorgVoiceWindow : DefaultWindow
|
||||
{
|
||||
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
|
||||
|
||||
public event Action<string>? OnVoiceSelected;
|
||||
public event Action<string>? OnVoicePreview;
|
||||
|
||||
private readonly List<TTSVoicePrototype> _voiceList = new();
|
||||
|
||||
public BorgVoiceWindow()
|
||||
{
|
||||
RobustXamlLoader.Load(this);
|
||||
IoCManager.InjectDependencies(this);
|
||||
|
||||
Title = Loc.GetString("borg-voice-window-title");
|
||||
|
||||
LoadVoiceList();
|
||||
SetupButtons();
|
||||
}
|
||||
|
||||
private void LoadVoiceList()
|
||||
{
|
||||
_voiceList.Clear();
|
||||
_voiceList.AddRange(_prototypeManager
|
||||
.EnumeratePrototypes<TTSVoicePrototype>()
|
||||
.Where(v => v.RoundStart)
|
||||
.OrderBy(v => Loc.GetString(v.Name)));
|
||||
}
|
||||
|
||||
private void SetupButtons()
|
||||
{
|
||||
VoiceOptionButton.OnItemSelected += args =>
|
||||
{
|
||||
VoiceOptionButton.SelectId(args.Id);
|
||||
var voice = _voiceList[args.Id];
|
||||
OnVoiceSelected?.Invoke(voice.ID);
|
||||
};
|
||||
|
||||
VoicePlayButton.OnPressed += _ =>
|
||||
{
|
||||
if (VoiceOptionButton.SelectedId != null)
|
||||
{
|
||||
var voice = _voiceList[VoiceOptionButton.SelectedId];
|
||||
OnVoicePreview?.Invoke(voice.ID);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public void UpdateState(BorgVoiceChangeState state)
|
||||
{
|
||||
VoiceOptionButton.Clear();
|
||||
|
||||
var selectedIndex = 0;
|
||||
for (var i = 0; i < _voiceList.Count; i++)
|
||||
{
|
||||
var voice = _voiceList[i];
|
||||
var name = Loc.GetString(voice.Name);
|
||||
|
||||
VoiceOptionButton.AddItem(name, i);
|
||||
|
||||
if (voice.ID == state.CurrentVoiceId)
|
||||
{
|
||||
selectedIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (_voiceList.Count > 0)
|
||||
{
|
||||
VoiceOptionButton.SelectId(selectedIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
23
Content.Client/Silicons/Borgs/BorgVoiceWindow.xaml
Normal file
23
Content.Client/Silicons/Borgs/BorgVoiceWindow.xaml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<DefaultWindow xmlns="https://spacestation14.io"
|
||||
xmlns:customControls="clr-namespace:Content.Client.Administration.UI.CustomControls"
|
||||
Title="{Loc 'borg-voice-window-title'}"
|
||||
MinSize="600 180"
|
||||
SetSize="600 180"
|
||||
Resizable="False">
|
||||
<BoxContainer Orientation="Vertical">
|
||||
<Label Text="{Loc 'borg-voice-window-description'}"
|
||||
Margin="5 5 5 0" HorizontalAlignment="Center"/>
|
||||
|
||||
<customControls:HSeparator Margin="5 5 5 5" />
|
||||
|
||||
|
||||
<OptionButton Name="VoiceOptionButton"
|
||||
HorizontalExpand="True" />
|
||||
|
||||
<BoxContainer Orientation="Horizontal" Margin="5 10 5 5" HorizontalAlignment="Center">
|
||||
<Button Name="VoicePlayButton"
|
||||
Text="{Loc 'borg-voice-window-play-button'}"
|
||||
Margin="0 0 5 0" />
|
||||
</BoxContainer>
|
||||
</BoxContainer>
|
||||
</DefaultWindow>
|
||||
|
|
@ -25,7 +25,6 @@ public sealed class TTSSystem : EntitySystem
|
|||
[Dependency] private readonly IConfigurationManager _cfg = default!;
|
||||
[Dependency] private readonly IResourceManager _res = default!;
|
||||
[Dependency] private readonly AudioSystem _audio = default!;
|
||||
[Dependency] private readonly SharedAudioSystem _sharedAudio = default!;
|
||||
[Dependency] private readonly IResourceCache _resourceCache = default!;
|
||||
[Dependency] private readonly IDependencyCollection _dependencyCollection = default!;
|
||||
[Dependency] private readonly IPlayerManager _playerManager = default!;
|
||||
|
|
@ -169,39 +168,12 @@ public sealed class TTSSystem : EntitySystem
|
|||
PlayTTSBytes(ev.Data, entity, audioParams);
|
||||
}
|
||||
|
||||
private void OnPlayMultiSpeakerTTS(PlayMultiSpeakerTTSEvent ev)
|
||||
{
|
||||
if (_volume == 0)
|
||||
return;
|
||||
|
||||
var volume = SharedAudioSystem.GainToVolume(_volume);
|
||||
|
||||
var audioParams = AudioParams.Default.WithVolume(volume);
|
||||
|
||||
foreach (var uid in ev.Speakers)
|
||||
{
|
||||
PlayTTSBytes(ev.SoundData, GetEntity(uid), audioParams);
|
||||
}
|
||||
}
|
||||
|
||||
private (EntityUid Entity, AudioComponent Component)? PlayTTSBytes(byte[] data, EntityUid? sourceUid = null, AudioParams? audioParams = null, bool globally = false)
|
||||
private (AudioResource Resource, ResPath FilePath)? AddTtsAudioResource(byte[] data)
|
||||
{
|
||||
if (data.Length == 0)
|
||||
return null;
|
||||
|
||||
// если sourceUid.Value.Id == 0 то значит эта сущность не прогружена на стороне клиента
|
||||
if (sourceUid is { Id: 0 } && !globally)
|
||||
return null;
|
||||
|
||||
_sawmill.Debug($"Play TTS audio {data.Length} bytes from {sourceUid} entity");
|
||||
|
||||
var finalParams = audioParams ?? AudioParams.Default;
|
||||
|
||||
var filePath = new ResPath($"{_fileIdx}.ogg");
|
||||
ContentRoot.AddOrUpdateFile(filePath, data);
|
||||
|
||||
var res = new AudioResource();
|
||||
res.Load(_dependencyCollection, Prefix / filePath);
|
||||
try
|
||||
{
|
||||
ContentRoot.AddOrUpdateFile(filePath, data);
|
||||
|
|
@ -212,10 +184,16 @@ public sealed class TTSSystem : EntitySystem
|
|||
_fileIdx++;
|
||||
return null;
|
||||
}
|
||||
var res = new AudioResource();
|
||||
res.Load(_dependencyCollection, Prefix / filePath);
|
||||
_resourceCache.CacheResource(Prefix / filePath, res);
|
||||
return (res, filePath);
|
||||
}
|
||||
|
||||
private (EntityUid Entity, AudioComponent Component)? PlayTTSResource(AudioResource res, ResPath filePath, EntityUid? sourceUid = null, AudioParams? audioParams = null, bool globally = false)
|
||||
{
|
||||
var finalParams = audioParams ?? AudioParams.Default;
|
||||
(EntityUid Entity, AudioComponent Component)? playing;
|
||||
|
||||
if (globally)
|
||||
{
|
||||
playing = _audio.PlayGlobal(res.AudioStream, null, finalParams);
|
||||
|
|
@ -231,13 +209,47 @@ public sealed class TTSSystem : EntitySystem
|
|||
playing = _audio.PlayGlobal(res.AudioStream, null, finalParams);
|
||||
}
|
||||
}
|
||||
|
||||
RemoveFileCursed(filePath);
|
||||
|
||||
_fileIdx++;
|
||||
return playing;
|
||||
}
|
||||
|
||||
private void OnPlayMultiSpeakerTTS(PlayMultiSpeakerTTSEvent ev)
|
||||
{
|
||||
if (_volume == 0)
|
||||
return;
|
||||
|
||||
var volume = SharedAudioSystem.GainToVolume(_volume);
|
||||
var audioParams = AudioParams.Default.WithVolume(volume).WithMaxDistance(30f);
|
||||
|
||||
var audioRes = AddTtsAudioResource(ev.SoundData);
|
||||
if (audioRes == null)
|
||||
return;
|
||||
|
||||
foreach (var uid in ev.Speakers)
|
||||
{
|
||||
PlayTTSResource(audioRes.Value.Resource, audioRes.Value.FilePath, GetEntity(uid), audioParams);
|
||||
}
|
||||
}
|
||||
|
||||
private (EntityUid Entity, AudioComponent Component)? PlayTTSBytes(byte[] data, EntityUid? sourceUid = null, AudioParams? audioParams = null, bool globally = false)
|
||||
{
|
||||
if (data.Length == 0)
|
||||
return null;
|
||||
|
||||
// если sourceUid.Value.Id == 0 то значит эта сущность не прогружена на стороне клиента
|
||||
if (sourceUid is { Id: 0 } && !globally)
|
||||
return null;
|
||||
|
||||
_sawmill.Debug($"Play TTS audio {data.Length} bytes from {sourceUid} entity");
|
||||
|
||||
var audioRes = AddTtsAudioResource(data);
|
||||
if (audioRes == null)
|
||||
return null;
|
||||
|
||||
return PlayTTSResource(audioRes.Value.Resource, audioRes.Value.FilePath, sourceUid, audioParams, globally);
|
||||
}
|
||||
|
||||
private void RemoveFileCursed(ResPath resPath)
|
||||
{
|
||||
ContentRoot.RemoveFile(resPath);
|
||||
|
|
|
|||
157
Content.Server/Silicons/Borgs/BorgVoiceSystem.cs
Normal file
157
Content.Server/Silicons/Borgs/BorgVoiceSystem.cs
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
using System.Linq;
|
||||
using Content.Server._Sunrise.TTS;
|
||||
using Content.Shared._Sunrise.TTS;
|
||||
using Content.Shared.Popups;
|
||||
using Content.Shared.Preferences;
|
||||
using Content.Shared.Silicons.Borgs.Components;
|
||||
using Content.Shared.UserInterface;
|
||||
using Content.Sunrise.Interfaces.Shared;
|
||||
using Robust.Server.GameObjects;
|
||||
using Robust.Server.Player;
|
||||
using Robust.Shared.Player;
|
||||
using Robust.Shared.Prototypes;
|
||||
|
||||
namespace Content.Server.Silicons.Borgs;
|
||||
|
||||
/// <summary>
|
||||
/// System that handles cyborg voice changing functionality.
|
||||
/// </summary>
|
||||
public sealed class BorgVoiceSystem : EntitySystem
|
||||
{
|
||||
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
|
||||
[Dependency] private readonly SharedPopupSystem _popup = default!;
|
||||
[Dependency] private readonly UserInterfaceSystem _uiSystem = default!;
|
||||
[Dependency] private readonly IPlayerManager _playerManager = default!;
|
||||
|
||||
private ISharedSponsorsManager? _sponsorsManager;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
|
||||
SubscribeLocalEvent<BorgVoiceComponent, BorgVoiceChangeActionEvent>(OnBorgVoiceChangeAction);
|
||||
SubscribeLocalEvent<BorgVoiceComponent, ComponentStartup>(OnBorgVoiceStartup);
|
||||
|
||||
// Subscribe to TTS voice transformation
|
||||
SubscribeLocalEvent<BorgVoiceComponent, TransformSpeakerVoiceEvent>(OnTransformSpeakerVoice);
|
||||
|
||||
// Initialize sponsors manager
|
||||
IoCManager.Instance!.TryResolveType(out _sponsorsManager);
|
||||
|
||||
// Subscribe to UI events
|
||||
Subs.BuiEvents<BorgVoiceComponent>(BorgVoiceUiKey.Key, subs =>
|
||||
{
|
||||
subs.Event<BorgVoiceChangeMessage>(OnBorgVoiceChangeMessage);
|
||||
});
|
||||
}
|
||||
|
||||
private void OnBorgVoiceChangeAction(EntityUid uid, BorgVoiceComponent component, BorgVoiceChangeActionEvent args)
|
||||
{
|
||||
if (!TryComp<BorgChassisComponent>(uid, out _))
|
||||
return;
|
||||
|
||||
// Open the voice selection UI
|
||||
if (!_uiSystem.HasUi(uid, BorgVoiceUiKey.Key))
|
||||
return;
|
||||
|
||||
// Get the player session for the performer
|
||||
if (!_playerManager.TryGetSessionByEntity(args.Performer, out var session))
|
||||
return;
|
||||
|
||||
var state = CreateVoiceChangeState(uid, component, session);
|
||||
_uiSystem.SetUiState(uid, BorgVoiceUiKey.Key, state);
|
||||
_uiSystem.OpenUi(uid, BorgVoiceUiKey.Key, session);
|
||||
}
|
||||
|
||||
private void OnBorgVoiceChangeMessage(EntityUid uid, BorgVoiceComponent component, BorgVoiceChangeMessage args)
|
||||
{
|
||||
if (!TryComp<BorgChassisComponent>(uid, out _))
|
||||
return;
|
||||
|
||||
// Get the player session for the actor
|
||||
if (!_playerManager.TryGetSessionByEntity(args.Actor, out var session))
|
||||
return;
|
||||
|
||||
// Validate the voice prototype exists and player can use it
|
||||
if (!CanUseVoice(args.VoiceId, session))
|
||||
{
|
||||
if (!_prototypeManager.TryIndex<TTSVoicePrototype>(args.VoiceId, out var voicePrototype))
|
||||
{
|
||||
_popup.PopupEntity(Loc.GetString("borg-voice-popup-invalid"), uid, args.Actor, PopupType.MediumCaution);
|
||||
return;
|
||||
}
|
||||
|
||||
if (voicePrototype.SponsorOnly)
|
||||
{
|
||||
_popup.PopupEntity(Loc.GetString("borg-voice-popup-sponsor-only"), uid, args.Actor, PopupType.MediumCaution);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the voice prototype for the success message
|
||||
if (!_prototypeManager.TryIndex<TTSVoicePrototype>(args.VoiceId, out var voice))
|
||||
return;
|
||||
|
||||
// Set the new voice
|
||||
component.SelectedVoiceId = args.VoiceId;
|
||||
Dirty(uid, component);
|
||||
|
||||
_popup.PopupEntity(Loc.GetString("borg-voice-popup-changed", ("voice", Loc.GetString(voice.Name))), uid, args.Actor, PopupType.Medium);
|
||||
|
||||
// Update UI
|
||||
var state = CreateVoiceChangeState(uid, component, session);
|
||||
_uiSystem.SetUiState(uid, BorgVoiceUiKey.Key, state);
|
||||
}
|
||||
|
||||
private void OnBorgVoiceStartup(EntityUid uid, BorgVoiceComponent component, ComponentStartup args)
|
||||
{
|
||||
// Set default voice if not already set
|
||||
if (component.SelectedVoiceId == null)
|
||||
{
|
||||
var defaultVoice = _prototypeManager
|
||||
.EnumeratePrototypes<TTSVoicePrototype>()
|
||||
.FirstOrDefault(v => v.RoundStart && !v.SponsorOnly);
|
||||
|
||||
if (defaultVoice != null)
|
||||
{
|
||||
component.SelectedVoiceId = defaultVoice.ID;
|
||||
Dirty(uid, component);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTransformSpeakerVoice(EntityUid uid, BorgVoiceComponent component, TransformSpeakerVoiceEvent args)
|
||||
{
|
||||
// Use the borg's selected voice instead of the default
|
||||
if (component.SelectedVoiceId != null)
|
||||
{
|
||||
args.VoiceId = component.SelectedVoiceId;
|
||||
}
|
||||
args.Effect = component.VoiceEffect;
|
||||
}
|
||||
|
||||
private BorgVoiceChangeState CreateVoiceChangeState(EntityUid uid, BorgVoiceComponent component, ICommonSession player)
|
||||
{
|
||||
var availableVoices = _prototypeManager
|
||||
.EnumeratePrototypes<TTSVoicePrototype>()
|
||||
.Where(v => v.RoundStart && CanUseVoice(v.ID, player))
|
||||
.Select(v => v.ID)
|
||||
.ToList();
|
||||
|
||||
return new BorgVoiceChangeState(component.SelectedVoiceId, availableVoices);
|
||||
}
|
||||
|
||||
private bool CanUseVoice(string voiceId, ICommonSession player)
|
||||
{
|
||||
if (!_prototypeManager.TryIndex<TTSVoicePrototype>(voiceId, out var voice))
|
||||
return false;
|
||||
|
||||
if (!voice.SponsorOnly)
|
||||
return true;
|
||||
|
||||
if (_sponsorsManager == null)
|
||||
return true;
|
||||
|
||||
return _sponsorsManager.TryGetPrototypes(player.UserId, out var allowedPrototypes) && allowedPrototypes.Contains(voiceId);
|
||||
}
|
||||
}
|
||||
|
|
@ -53,6 +53,7 @@ public sealed class AnnouncementSpeakerSystem : EntitySystem
|
|||
|
||||
private bool _isEnabled;
|
||||
private string _defaultAnnounceVoice = "Hanson";
|
||||
private string _announceEffect = string.Empty;
|
||||
|
||||
// Queue system for preventing overlapping announcements
|
||||
private readonly Queue<QueuedAnnouncement> _announcementQueue = new();
|
||||
|
|
@ -64,6 +65,7 @@ public sealed class AnnouncementSpeakerSystem : EntitySystem
|
|||
{
|
||||
base.Initialize();
|
||||
_cfg.OnValueChanged(SunriseCCVars.TTSEnabled, v => _isEnabled = v, true);
|
||||
_cfg.OnValueChanged(SunriseCCVars.TTSAnnounceEffect, OnAnnounceEffectChanged, true);
|
||||
}
|
||||
|
||||
public override void Update(float frameTime)
|
||||
|
|
@ -72,6 +74,11 @@ public sealed class AnnouncementSpeakerSystem : EntitySystem
|
|||
ProcessAnnouncementQueue();
|
||||
}
|
||||
|
||||
private void OnAnnounceEffectChanged(string value)
|
||||
{
|
||||
_announceEffect = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes the announcement queue and plays the next announcement if none is currently playing.
|
||||
/// </summary>
|
||||
|
|
@ -225,7 +232,7 @@ public sealed class AnnouncementSpeakerSystem : EntitySystem
|
|||
{
|
||||
try
|
||||
{
|
||||
return await _ttsSystem.GenerateTTS(text, voicePrototype, isAnnounce: true);
|
||||
return await _ttsSystem.GenerateTTS(text, voicePrototype, _announceEffect);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -28,30 +28,18 @@ public sealed class TTSManager
|
|||
"tts_wanted_count",
|
||||
"Amount of wanted TTS audio.");
|
||||
|
||||
private static readonly Counter WantedRadioCount = Metrics.CreateCounter(
|
||||
"tts_wanted_radio_count",
|
||||
"Amount of wanted TTS radio audio.");
|
||||
|
||||
private static readonly Counter WantedAnnounceCount = Metrics.CreateCounter(
|
||||
"tts_wanted_announce_count",
|
||||
"Amount of wanted TTS Announce audio.");
|
||||
|
||||
[Dependency] private readonly IConfigurationManager _cfg = default!;
|
||||
|
||||
private readonly HttpClient _httpClient = new();
|
||||
|
||||
private ISawmill _sawmill = default!;
|
||||
private string _apiUrl = string.Empty;
|
||||
private string _radioEffect = string.Empty;
|
||||
private string _announceEffect = string.Empty;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
_sawmill = Logger.GetSawmill("tts");
|
||||
_cfg.OnValueChanged(SunriseCCVars.TTSApiUrl, OnApiUrlChanged, true);
|
||||
_cfg.OnValueChanged(SunriseCCVars.TTSApiToken, OnApiTokenChanged, true);
|
||||
_cfg.OnValueChanged(SunriseCCVars.TTSRadioEffect, OnRadioEffectChanged, true);
|
||||
_cfg.OnValueChanged(SunriseCCVars.TTSAnnounceEffect, OnAnnounceEffectChanged, true);
|
||||
}
|
||||
|
||||
private void OnApiUrlChanged(string value)
|
||||
|
|
@ -64,16 +52,6 @@ public sealed class TTSManager
|
|||
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", value);
|
||||
}
|
||||
|
||||
private void OnRadioEffectChanged(string value)
|
||||
{
|
||||
_radioEffect = value;
|
||||
}
|
||||
|
||||
private void OnAnnounceEffectChanged(string value)
|
||||
{
|
||||
_announceEffect = value;
|
||||
}
|
||||
|
||||
public async Task<byte[]?> ConvertTextToSpeech(TTSVoicePrototype voicePrototype, string text, string? effect = null)
|
||||
{
|
||||
WantedCount.Inc();
|
||||
|
|
@ -148,20 +126,6 @@ public sealed class TTSManager
|
|||
return uriBuilder.ToString();
|
||||
}
|
||||
|
||||
public async Task<byte[]?> ConvertTextToSpeechRadio(TTSVoicePrototype voicePrototype, string text)
|
||||
{
|
||||
WantedRadioCount.Inc();
|
||||
var soundData = await ConvertTextToSpeech(voicePrototype, text, _radioEffect);
|
||||
return soundData;
|
||||
}
|
||||
|
||||
public async Task<byte[]?> ConvertTextToSpeechAnnounce(TTSVoicePrototype voicePrototype, string text)
|
||||
{
|
||||
WantedAnnounceCount.Inc();
|
||||
var soundData = await ConvertTextToSpeech(voicePrototype, text, _announceEffect);
|
||||
return soundData;
|
||||
}
|
||||
|
||||
private record GenerateVoiceRequest
|
||||
{
|
||||
[JsonPropertyName("text")]
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ using Content.Shared._Sunrise.AnnouncementSpeaker.Components;
|
|||
using Content.Shared._Sunrise.AnnouncementSpeaker.Events;
|
||||
using Robust.Server.GameObjects;
|
||||
using Robust.Shared.Audio;
|
||||
using Content.Shared.Silicons.Borgs.Components;
|
||||
using Robust.Shared.Audio.Systems;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Shared.Player;
|
||||
|
|
@ -54,10 +55,12 @@ public sealed partial class TTSSystem : EntitySystem
|
|||
private List<ICommonSession> _ignoredRecipients = new();
|
||||
private const float WhisperVoiceVolumeModifier = 0.6f; // how far whisper goes in world units
|
||||
private const int WhisperVoiceRange = 3; // how far whisper goes in world units
|
||||
private string _radioEffect = string.Empty;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
_cfg.OnValueChanged(SunriseCCVars.TTSEnabled, v => _isEnabled = v, true);
|
||||
_cfg.OnValueChanged(SunriseCCVars.TTSRadioEffect, OnRadioEffectChanged, true);
|
||||
|
||||
SubscribeLocalEvent<TransformSpeechEvent>(OnTransformSpeech);
|
||||
SubscribeLocalEvent<TTSComponent, EntitySpokeEvent>(OnEntitySpoke);
|
||||
|
|
@ -68,6 +71,11 @@ public sealed partial class TTSSystem : EntitySystem
|
|||
SubscribeNetworkEvent<ClientOptionTTSEvent>(OnClientOptionTTS);
|
||||
}
|
||||
|
||||
private void OnRadioEffectChanged(string value)
|
||||
{
|
||||
_radioEffect = value;
|
||||
}
|
||||
|
||||
private async void OnRequestPreviewTTS(RequestPreviewTTSEvent ev, EntitySessionEventArgs args)
|
||||
{
|
||||
if (!_isEnabled ||
|
||||
|
|
@ -115,7 +123,7 @@ public sealed partial class TTSSystem : EntitySystem
|
|||
RaiseLocalEvent(args.Source, accentEvent);
|
||||
var message = accentEvent.Text;
|
||||
|
||||
HandleRadio(args.Receivers, message, protoVoice);
|
||||
HandleRadio(args.Receivers, message, protoVoice, voiceEv.Effect);
|
||||
}
|
||||
|
||||
private bool GetVoicePrototype(string voiceId, [NotNullWhen(true)] out TTSVoicePrototype? voicePrototype)
|
||||
|
|
@ -237,10 +245,10 @@ public sealed partial class TTSSystem : EntitySystem
|
|||
return;
|
||||
}
|
||||
|
||||
HandleSay(uid, message, protoVoice);
|
||||
HandleSay(uid, message, protoVoice, voiceEv.Effect);
|
||||
}
|
||||
|
||||
private async void HandleSay(EntityUid uid, string message, TTSVoicePrototype voicePrototype)
|
||||
private async void HandleSay(EntityUid uid, string message, TTSVoicePrototype voicePrototype, string? effect)
|
||||
{
|
||||
var recipients = Filter.Pvs(uid, 1F).RemovePlayers(_ignoredRecipients);
|
||||
|
||||
|
|
@ -248,7 +256,7 @@ public sealed partial class TTSSystem : EntitySystem
|
|||
if (!recipients.Recipients.Any())
|
||||
return;
|
||||
|
||||
var soundData = await GenerateTTS(message, voicePrototype);
|
||||
var soundData = await GenerateTTS(message, voicePrototype, effect);
|
||||
|
||||
if (soundData is null)
|
||||
return;
|
||||
|
|
@ -293,9 +301,9 @@ public sealed partial class TTSSystem : EntitySystem
|
|||
}
|
||||
}
|
||||
|
||||
private async void HandleRadio(EntityUid[] uids, string message, TTSVoicePrototype voicePrototype)
|
||||
private async void HandleRadio(EntityUid[] uids, string message, TTSVoicePrototype voicePrototype, string? effect = null)
|
||||
{
|
||||
var soundData = await GenerateTTS(message, voicePrototype, isRadio: true);
|
||||
var soundData = await GenerateTTS(message, voicePrototype, _radioEffect);
|
||||
if (soundData is null)
|
||||
return;
|
||||
|
||||
|
|
@ -303,7 +311,7 @@ public sealed partial class TTSSystem : EntitySystem
|
|||
}
|
||||
|
||||
// ReSharper disable once InconsistentNaming
|
||||
public async Task<byte[]?> GenerateTTS(string text, TTSVoicePrototype voicePrototype, bool isRadio = false, bool isAnnounce = false)
|
||||
public async Task<byte[]?> GenerateTTS(string text, TTSVoicePrototype voicePrototype, string? effect = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -312,17 +320,7 @@ public sealed partial class TTSSystem : EntitySystem
|
|||
if (char.IsLetter(textSanitized[^1]))
|
||||
textSanitized += ".";
|
||||
|
||||
if (isRadio)
|
||||
{
|
||||
return await _ttsManager.ConvertTextToSpeechRadio(voicePrototype, textSanitized);
|
||||
}
|
||||
|
||||
if (isAnnounce)
|
||||
{
|
||||
return await _ttsManager.ConvertTextToSpeechAnnounce(voicePrototype, textSanitized);
|
||||
}
|
||||
|
||||
return await _ttsManager.ConvertTextToSpeech(voicePrototype, textSanitized);
|
||||
return await _ttsManager.ConvertTextToSpeech(voicePrototype, textSanitized, effect);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
|
@ -338,11 +336,13 @@ public sealed class TransformSpeakerVoiceEvent : EntityEventArgs
|
|||
{
|
||||
public EntityUid Sender;
|
||||
public string VoiceId;
|
||||
public string? Effect;
|
||||
|
||||
public TransformSpeakerVoiceEvent(EntityUid sender, string voiceId)
|
||||
public TransformSpeakerVoiceEvent(EntityUid sender, string voiceId, string? effect = null)
|
||||
{
|
||||
Sender = sender;
|
||||
VoiceId = voiceId;
|
||||
Effect = effect;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ public sealed class InnateItemSystem : EntitySystem
|
|||
[Dependency] private readonly SharedInteractionSystem _interactionSystem = default!;
|
||||
[Dependency] private readonly SharedContainerSystem _containerSystem = default!;
|
||||
[Dependency] private readonly ActionContainerSystem _actionContainer = default!;
|
||||
[Dependency] private readonly MetaDataSystem _metadata = default!;
|
||||
|
||||
private static readonly EntProtoId InnateEntityTargetAction = "InnateEntityTargetAction";
|
||||
private static readonly EntProtoId InnateInstantActionAction = "InnateInstantActionAction";
|
||||
|
|
@ -81,7 +82,8 @@ public sealed class InnateItemSystem : EntitySystem
|
|||
{
|
||||
foreach (var itemProto in prototypeIds)
|
||||
{
|
||||
if (itemProto == null) continue;
|
||||
if (itemProto == null)
|
||||
continue;
|
||||
|
||||
var spawnedItem = Spawn(itemProto);
|
||||
|
||||
|
|
@ -97,7 +99,8 @@ public sealed class InnateItemSystem : EntitySystem
|
|||
|
||||
var action = Spawn(actionPrototypeId);
|
||||
|
||||
_actionsSystem.SetIcon(action, new SpriteSpecifier.EntityPrototype(MetaData(spawnedItem).EntityPrototype!.ID));
|
||||
_actionsSystem.SetIcon(action,
|
||||
new SpriteSpecifier.EntityPrototype(MetaData(spawnedItem).EntityPrototype!.ID));
|
||||
|
||||
// Устанавливаем соответствующий тип события в зависимости от типа действия
|
||||
if (isEntityTarget)
|
||||
|
|
@ -105,6 +108,10 @@ public sealed class InnateItemSystem : EntitySystem
|
|||
else
|
||||
_actionsSystem.SetEvent(action, new InnateInstantActionEvent(spawnedItem));
|
||||
|
||||
_metadata.SetEntityName(action, MetaData(spawnedItem).EntityName);
|
||||
_metadata.SetEntityDescription(action, MetaData(spawnedItem).EntityDescription);
|
||||
|
||||
_actionContainer.AddAction(uid, action);
|
||||
_actionsSystem.AddAction(uid, action, uid);
|
||||
component.Actions.Add(action);
|
||||
}
|
||||
|
|
|
|||
47
Content.Shared/_Sunrise/TTS/BorgVoiceChangeEvent.cs
Normal file
47
Content.Shared/_Sunrise/TTS/BorgVoiceChangeEvent.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
using Content.Shared.Actions;
|
||||
using Robust.Shared.Serialization;
|
||||
|
||||
namespace Content.Shared._Sunrise.TTS;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when a cyborg wants to change their TTS voice.
|
||||
/// </summary>
|
||||
public sealed partial class BorgVoiceChangeActionEvent : InstantActionEvent
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event sent from client to server to change borg voice.
|
||||
/// </summary>
|
||||
[Serializable, NetSerializable]
|
||||
public sealed partial class BorgVoiceChangeMessage : BoundUserInterfaceMessage
|
||||
{
|
||||
public string VoiceId;
|
||||
|
||||
public BorgVoiceChangeMessage(string voiceId)
|
||||
{
|
||||
VoiceId = voiceId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event sent from server to client to update borg voice UI.
|
||||
/// </summary>
|
||||
[Serializable, NetSerializable]
|
||||
public sealed partial class BorgVoiceChangeState : BoundUserInterfaceState
|
||||
{
|
||||
public string? CurrentVoiceId;
|
||||
public List<string> AvailableVoices;
|
||||
|
||||
public BorgVoiceChangeState(string? currentVoiceId, List<string> availableVoices)
|
||||
{
|
||||
CurrentVoiceId = currentVoiceId;
|
||||
AvailableVoices = availableVoices;
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable, NetSerializable]
|
||||
public enum BorgVoiceUiKey : byte
|
||||
{
|
||||
Key
|
||||
}
|
||||
22
Content.Shared/_Sunrise/TTS/BorgVoiceComponent.cs
Normal file
22
Content.Shared/_Sunrise/TTS/BorgVoiceComponent.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
using Robust.Shared.GameStates;
|
||||
using Robust.Shared.Prototypes;
|
||||
|
||||
namespace Content.Shared._Sunrise.TTS;
|
||||
|
||||
/// <summary>
|
||||
/// Component for cyborgs that allows them to change their TTS voice.
|
||||
/// </summary>
|
||||
[RegisterComponent, NetworkedComponent]
|
||||
public sealed partial class BorgVoiceComponent : Component
|
||||
{
|
||||
/// <summary>
|
||||
/// The currently selected voice prototype ID.
|
||||
/// </summary>
|
||||
[ViewVariables(VVAccess.ReadWrite)]
|
||||
[DataField]
|
||||
public ProtoId<TTSVoicePrototype>? SelectedVoiceId { get; set; }
|
||||
|
||||
[ViewVariables(VVAccess.ReadWrite)]
|
||||
[DataField]
|
||||
public string VoiceEffect = "robot";
|
||||
}
|
||||
9
Resources/Locale/en-US/_strings/_sunrise/borg-voice.ftl
Normal file
9
Resources/Locale/en-US/_strings/_sunrise/borg-voice.ftl
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Borg Voice Change UI
|
||||
borg-voice-window-title = Voice Settings
|
||||
borg-voice-window-description = Select your TTS voice. Cyborgs can speak with any voice due to their speakers.
|
||||
borg-voice-window-voice-label = Voice:
|
||||
borg-voice-window-play-button = Preview
|
||||
|
||||
# Action
|
||||
action-name-change-borg-voice = Change Voice
|
||||
action-description-change-borg-voice = Change your TTS voice. Cyborgs can speak with any voice due to their speakers.
|
||||
13
Resources/Locale/ru-RU/_strings/_sunrise/borg-voice.ftl
Normal file
13
Resources/Locale/ru-RU/_strings/_sunrise/borg-voice.ftl
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Borg Voice Change UI
|
||||
borg-voice-window-title = Настройки голоса
|
||||
borg-voice-window-description = Выберите голос TTS.
|
||||
borg-voice-window-play-button = Прослушать
|
||||
|
||||
# Action
|
||||
action-name-change-borg-voice = Сменить голос
|
||||
action-description-change-borg-voice = Измените голос TTS. Киборги могут говорить любым голосом благодаря динамикам.
|
||||
|
||||
# Popups
|
||||
borg-voice-popup-sponsor-only = Этот голос доступен только спонсорам!
|
||||
borg-voice-popup-changed = Голос изменён на { $voice }!
|
||||
borg-voice-popup-invalid = Выбран несуществующий голос!
|
||||
|
|
@ -74,12 +74,15 @@
|
|||
# Only used for NT borgs that can switch type, defined here to avoid copy-pasting the rest of this component.
|
||||
enum.BorgSwitchableTypeUiKey.SelectBorgType:
|
||||
type: BorgSelectTypeUserInterface
|
||||
enum.BorgVoiceUiKey.Key:
|
||||
type: BorgVoiceBoundUserInterface
|
||||
- type: ActivatableUI
|
||||
key: enum.BorgUiKey.Key
|
||||
- type: SiliconLawBound
|
||||
- type: ActionGrant
|
||||
actions:
|
||||
- ActionViewLaws
|
||||
- ActionChangeBorgVoice
|
||||
- type: EmagSiliconLaw
|
||||
stunTime: 5
|
||||
- type: SiliconLawProvider
|
||||
|
|
@ -117,6 +120,7 @@
|
|||
allowSelfRepair: false
|
||||
# Sunrise-End
|
||||
- type: BorgChassis
|
||||
- type: BorgVoice # Sunrise-TTS: Allow cyborgs to change their voice
|
||||
- type: LockingWhitelist
|
||||
blacklist:
|
||||
components:
|
||||
|
|
|
|||
|
|
@ -110,3 +110,18 @@
|
|||
priority: 11
|
||||
- type: InstantAction
|
||||
event: !type:CrewManifestOpenActionEvent
|
||||
|
||||
- type: entity
|
||||
parent: BaseAction
|
||||
id: ActionChangeBorgVoice
|
||||
name: Change Voice
|
||||
description: Change your TTS voice. Cyborgs can speak with any voice due to their speakers.
|
||||
categories: [ HideSpawnMenu ]
|
||||
components:
|
||||
- type: Action
|
||||
icon: { sprite: Interface/Actions/actions_borg.rsi, state: select-type }
|
||||
itemIconStyle: NoItem
|
||||
useDelay: 0.5
|
||||
priority: 10
|
||||
- type: InstantAction
|
||||
event: !type:BorgVoiceChangeActionEvent
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue