Files
sbox-public/engine/Sandbox.Engine/Scene/Components/Audio/VoiceComponent.cs
Garry Newman 4fd85ce69d Add SkinnedModelRenderer.SpeakSound with baked viseme lipsync; remove dead native code (#5306)
// SkinnedModelRenderer - the only new entry point for speaking
SoundHandle SpeakSound( SoundEvent sound, GameObject mouth = null )
SoundHandle SpeakSound( SoundFile sound, float volume = 1, float pitch = 1, GameObject mouth = null )

// SoundFile - the authored lipsync track, read from the compiled vsnd
VisemeTrack Visemes { get; }   // null = no track (or not loaded yet, see IsValidForPlayback)

// VisemeTrack - visemes over time, authored in the sound editor
IReadOnlyList<VisemeFrame> Frames { get; }
float Duration { get; }
bool Sample( float time, Span<float> weights )

// VisemeFrame - one viseme span (struct: Viseme, StartTime, EndTime)

// Visemes - the standard 15-viseme table, OVR order
static IReadOnlyList<string> MorphNames { get; }
static int Count { get; }

// MorphCollection - the one shared viseme blend (sounds, voice chat, editor preview)
void ApplyVisemes( ReadOnlySpan<float> visemeWeights, float scale = 1, float smoothTime = 0 )

// ModelMorphs - per-model viseme weight table, cached managed-side (no interop on lookups)
float GetVisemeMorph( int viseme, int morph )
float GetVisemeMorph( string viseme, int morph )

// ResourceCompileContext - inject managed data into natively-compiled resources
void WriteBlock( string blockName, ReadOnlySpan<byte> data )   // four-char block names, eg "LIPS"
void WriteBlockJson( string blockName, object obj )
JsonObject ReadMeta()                                          // the asset's .meta, registers a compile dependency
T ReadMeta<T>( string key, T defaultValue = default )

// ResourceLoadContext - read blocks back when the resource loads (Resource.OnLoaded, engine-internal for now)
// public ref struct, only valid during the load callback
bool Exists( string blockName )
ReadOnlySpan<byte> ReadData( string blockName )
string ReadString( string blockName )
T ReadJson<T>( string blockName, T defaultValue = default )
2026-07-09 20:04:02 +01:00

373 lines
8.4 KiB
C#

using Sandbox.Audio;
namespace Sandbox;
/// <summary>
/// Records and transmits voice/microphone input to other players.
/// </summary>
[Expose]
[Category( "Audio" )]
[Title( "Voice Transmitter" )]
[Icon( "mic" )]
[Tint( EditorTint.Green )]
public class Voice : Component
{
static Voice singleRecorder;
[Expose]
public enum ActivateMode
{
[Icon( "hearing" )]
[Description( "Always recording and transmitting voice" )]
AlwaysOn,
[Icon( "touch_app" )]
[Description( "Hold a button down to talk" )]
PushToTalk,
[Icon( "science" )]
[Description( "Toggle recording by switching IsListening to true or false" )]
Manual
}
[Property] public float Volume { get; set; } = 1.0f;
[Property] public ActivateMode Mode { get; set; }
[Property, InputAction, ShowIf( nameof( Mode ), ActivateMode.PushToTalk )] public string PushToTalkInput { get; set; } = "voice";
[Property] public bool WorldspacePlayback { get; set; } = true;
[Description( "Play the sound of your own voice" )]
[Property] public bool Loopback { get; set; } = false;
[Property, ToggleGroup( "LipSync", Label = "Lip Sync" )]
public bool LipSync { get; set; } = true;
[Property, Group( "LipSync" )]
public SkinnedModelRenderer Renderer { get; set; }
[Property, Group( "LipSync" ), Range( 0, 5 )]
public float MorphScale { get; set; } = 3.0f;
[Property, Group( "LipSync" ), Range( 0, 1 )]
public float MorphSmoothTime { get; set; } = 0.1f;
/// <summary>
/// How long has it been since this sound played?
/// </summary>
public RealTimeSince LastPlayed { get; private set; }
/// <summary>
/// Laughter score for the current audio frame, between 0 and 1
/// </summary>
public float LaughterScore => sound.IsValid() ? sound.LipSync.LaughterScore : 0;
private bool recording = false;
private SoundStream soundStream;
private SoundHandle sound;
private MixerHandle targetMixer;
/// <summary>
/// Which mixer to target. Must be a descendant of the Voice mixer. Defaults to the Voice mixer if not set or invalid.
/// </summary>
[Property, ParentMixer( "Voice" )]
public MixerHandle VoiceMixer
{
get => targetMixer;
set
{
if ( value == targetMixer )
return;
targetMixer = value;
if ( sound.IsValid() )
sound.TargetMixer = targetMixer.GetOrDefault();
}
}
public Mixer TargetMixer
{
get => targetMixer.Get( Mixer.Voice );
set => VoiceMixer = value;
}
private float distance = 15_000f;
/// <inheritdoc cref="SoundHandle.Distance"/>
[Property, AudioDistanceFloat]
public float Distance
{
get => distance;
set
{
if ( value == distance )
return;
distance = value;
if ( sound.IsValid() )
sound.Distance = distance;
}
}
private Curve falloff = new( new( 0, 1, 0, -1.8f ), new( 0.05f, 0.22f, 3.5f, -3.5f ), new( 0.2f, 0.04f, 0.16f, -0.16f ), new( 1, 0 ) );
/// <inheritdoc cref="SoundHandle.Falloff"/>
[Property]
public Curve Falloff
{
get => falloff;
set
{
falloff = value;
if ( sound.IsValid() )
sound.Falloff = falloff;
}
}
/// <summary>
/// A list of 15 lipsync viseme weights. Requires <see cref="LipSync"/> to be enabled.
/// </summary>
public IReadOnlyList<float> Visemes => sound.IsValid() ? sound.LipSync.Visemes : Array.Empty<float>();
internal override void OnEnabledInternal()
{
VoiceManager.OnCompressedVoiceData += OnVoice;
soundStream = new SoundStream( VoiceManager.SampleRate );
// Ensure that the Mixer selected is a Voice mixer
var currentMixer = targetMixer.GetOrDefault();
if ( !currentMixer.IsDescendantOf( Mixer.Voice ) )
{
targetMixer = Mixer.Voice;
}
base.OnEnabledInternal();
}
internal override void OnDisabledInternal()
{
base.OnDisabledInternal();
VoiceManager.OnCompressedVoiceData -= OnVoice;
if ( recording )
{
VoiceManager.StopRecording();
recording = false;
}
if ( sound is not null )
{
sound.Finished = true;
sound = null;
}
soundStream?.Dispose();
soundStream = null;
}
public bool IsRecording
{
get => recording;
}
private bool _isListening;
/// <summary>
/// Returns true if the mic is listening. Even if it's listening, it might
/// not be playing - because it will only record and transmit if it can hear sound.
/// </summary>
public bool IsListening
{
set => _isListening = value;
get
{
if ( IsProxy ) return false;
if ( Preferences.VoiceMode == VoiceMode.Disabled ) return false;
if ( Preferences.VoiceMode == VoiceMode.OpenMicrophone )
return Mode == ActivateMode.AlwaysOn || Mode == ActivateMode.PushToTalk || (Mode == ActivateMode.Manual && _isListening);
// PushToTalk: always require key press regardless of component mode
return Input.Down( PushToTalkInput );
}
}
/// <summary>
/// Measure of audio loudness.
/// </summary>
public float Amplitude => sound.IsValid() ? sound.Amplitude : 0;
private void UpdateSound()
{
if ( !sound.IsValid() ) return;
sound.Volume = Volume;
sound.Loopback = !IsProxy && !Loopback;
if ( WorldspacePlayback )
{
sound.Position = WorldPosition;
sound.OcclusionEnabled = true;
}
else
{
sound.ListenLocal = true;
// in ListenLocal mode we could let them place the sound in screen space,
// so it plays back to the left on one team, to the right on others etc
sound.Position = Vector3.Forward * 10.0f;
}
}
protected sealed override void OnUpdate()
{
UpdateMorphs();
UpdateSound();
// Stop the sound if we haven't received voice data for a while
// This also stops LipSync processing which runs per-frame
// Use Finished instead of Dispose() so the audio thread can safely wrap up
// before TickInternal disposes it - avoids concurrent CBinauralEffect release/create.
if ( sound.IsValid() && LastPlayed > 1.0f )
{
sound.Finished = true;
sound = null;
}
if ( !VoiceManager.IsValid )
return;
if ( IsListening )
{
if ( !recording )
{
VoiceManager.StartRecording();
recording = true;
singleRecorder = this;
}
}
else if ( recording )
{
if ( singleRecorder == this )
{
VoiceManager.StopRecording();
}
recording = false;
}
}
/// <summary>
/// Exclude these connection from hearing our voice.
/// </summary>
protected virtual IEnumerable<Connection> ExcludeFilter()
{
return Enumerable.Empty<Connection>();
}
/// <summary>
/// Whether we want to hear voice from a particular connection.
/// </summary>
protected virtual bool ShouldHearVoice( Connection connection )
{
return true;
}
private void OnVoice( Memory<byte> compressed )
{
if ( IsProxy ) return;
if ( singleRecorder != this ) return;
if ( Networking.System is not null )
{
using ( Rpc.FilterExclude( ExcludeFilter() ) )
{
Msg_Voice( compressed.ToArray() );
}
}
else
{
Msg_Voice( compressed.ToArray() );
}
}
[Rpc.Broadcast( NetFlags.OwnerOnly | NetFlags.UnreliableNoDelay )]
private void Msg_Voice( byte[] buffer )
{
if ( buffer == null || buffer.Length == 0 )
return;
if ( Application.IsHeadless )
return;
if ( !ShouldHearVoice( Rpc.Caller ) )
return;
OnVoice( buffer );
}
private void UpdateMorphs()
{
if ( !Renderer.IsValid() )
return;
// Nothing said for a while - the morphs have eased back to the animation
if ( LastPlayed > 1.0f )
return;
var model = Renderer.Model;
if ( model is null || model.MorphCount == 0 )
return;
var sceneModel = Renderer.SceneModel;
if ( !sceneModel.IsValid() )
return;
// Drive the mouth from the live voice analysis. Voice packets are bursty,
// so hold the current shape through short gaps, then ease back to zero.
ReadOnlySpan<float> visemes = default;
if ( LastPlayed < 0.2f && sound.IsValid() && sound.LipSync.VisemeWeights is { } live )
{
visemes = live;
}
sceneModel.Morphs.ApplyVisemes( visemes, MorphScale, MorphSmoothTime );
}
private unsafe void OnVoice( byte[] buffer )
{
if ( buffer.Length < 2 )
return;
if ( soundStream is null )
return;
VoiceManager.Uncompress( buffer, samples =>
{
if ( !sound.IsValid() )
{
sound = soundStream.Play();
sound.TargetMixer = TargetMixer;
sound.Distance = Distance;
sound.Falloff = Falloff;
sound.LipSync.Enabled = LipSync && Renderer.IsValid();
sound.IsVoice = true;
}
soundStream.WriteData( samples.Span );
LastPlayed = 0;
UpdateSound();
} );
}
[AttributeUsage( AttributeTargets.Property )]
public class ParentMixerAttribute : Attribute
{
public string MixerName { get; }
public ParentMixerAttribute( string mixerName )
{
MixerName = mixerName;
}
}
}