Files
sbox-public/engine/Sandbox.Engine/Systems/Audio/SoundHandle.LipSync.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

174 lines
4.4 KiB
C#

using NativeEngine;
using System.Collections.Concurrent;
namespace Sandbox;
public partial class SoundHandle
{
/// <summary>
/// Access lipsync processing.
/// </summary>
public LipSyncAccessor LipSync { get; private init; } = new();
public class LipSyncAccessor
{
/// <summary>
/// A list of 15 lipsync viseme weights. Requires <see cref="Enabled"/> to be true.
/// </summary>
public IReadOnlyList<float> Visemes => _visemesReadOnly ?? (IReadOnlyList<float>)Array.Empty<float>();
/// <summary>
/// The viseme weights as a raw array for the engine's per-frame readers - no
/// allocation, no interface dispatch. Null until <see cref="Enabled"/> is set.
/// </summary>
internal float[] VisemeWeights => _visemes;
/// <summary>
/// Count from start of recognition.
/// </summary>
public int FrameNumber { get; private set; }
/// <summary>
/// Frame delay in milliseconds.
/// </summary>
public int FrameDelay { get; private set; }
/// <summary>
/// Laughter score for the current audio frame.
/// </summary>
public float LaughterScore { get; private set; }
/// <summary>
/// Enables lipsync processing.
/// </summary>
public bool Enabled
{
get => _enabled;
set
{
if ( _enabled == value )
return;
if ( value )
{
EnableLipSync();
}
else
{
DisableLipSync();
}
}
}
// Volatile so the mix thread's ProcessLipSync guard fires without a lock.
volatile bool _enabled;
private uint _context;
private float[] _visemes;
private System.Collections.ObjectModel.ReadOnlyCollection<float> _visemesReadOnly;
// Contexts waiting to be destroyed at the start of the next MixOneBuffer().
static readonly ConcurrentQueue<uint> _destructionQueue = new();
/// <summary>
/// Drain OVR contexts queued by DisableLipSync(). Called by MixingThread at the start
/// of each MixOneBuffer(), guaranteeing no context is destroyed while ProcessLipSync uses it.
/// </summary>
internal static void DrainDestructionQueue()
{
while ( _destructionQueue.TryDequeue( out var ctx ) )
OVRLipSyncGlobal.ovrLipSync_DestroyContext( ctx );
}
internal LipSyncAccessor()
{
}
private void EnableLipSync()
{
if ( _enabled ) return;
// Create resources first, then set _enabled = true (volatile release).
// The mix thread sees _enabled = true only after _context and _visemes are ready.
OVRLipSyncGlobal.ovrLipSync_CreateContextEx(
out _context,
OVRLipSync.ContextProvider.Enhanced_with_Laughter,
VoiceManager.SampleRate,
true );
_visemes = new float[(int)OVRLipSync.Viseme.Count];
_visemesReadOnly = Array.AsReadOnly( _visemes );
_enabled = true; // volatile write
}
internal void DisableLipSync()
{
if ( !_enabled ) return;
// Set _enabled = false (volatile write) FIRST so the mix thread's ProcessLipSync
// guard fires before any context is destroyed.
_enabled = false;
// Queue the context for destruction at the start of the next MixOneBuffer().
// We never destroy it here because the mix thread might still be in ProcessLipSync
// having read _enabled = true just before we set it to false.
_destructionQueue.Enqueue( _context );
_context = 0;
// Leave _visemes; ProcessLipSync's !_enabled guard prevents it from touching
// the array, and it will be replaced on re-enable or collected by GC.
FrameNumber = 0;
FrameDelay = 0;
LaughterScore = 0;
}
internal unsafe void ProcessLipSync( Audio.MixBuffer buffer )
{
// Volatile read: if DisableLipSync() has set _enabled = false, we bail here
// before touching _context or _visemes, both of which may have been cleared.
if ( !_enabled )
return;
if ( buffer is null )
return;
if ( buffer._native.IsNull )
return;
var pData = buffer._native.GetDataPointer();
if ( pData == IntPtr.Zero )
return;
if ( _visemes is null )
return;
fixed ( float* pVisemes = _visemes )
{
var frame = new OVRLipSync.Frame
{
Visemes = (IntPtr)pVisemes,
VisemesLength = (uint)_visemes.Length,
};
var r = OVRLipSyncGlobal.ovrLipSync_ProcessFrameEx(
_context,
pData,
Audio.AudioEngine.MixBufferSize,
OVRLipSync.AudioDataType.F32_Mono,
ref frame );
if ( r == OVRLipSync.Result.Success )
{
FrameNumber = frame.FrameNumber;
FrameDelay = frame.FrameDelay;
LaughterScore = frame.LaughterScore;
}
else
{
Log.Warning( r );
}
}
}
}
}