Audio/FaceAudio.cs
using System;
using Sandbox;
using SquishyTerry.Sim;
namespace SquishyTerry.Audio;
/// <summary>
/// Plays the face's procedural voice: runs <see cref="FaceSynth"/> (the original game's audio patch) and streams it
/// through a <see cref="SoundStream"/>. If a sibling <see cref="SquishyFace"/> exists, its state is sent to the synth
/// every frame exactly like the original game did (volume, area, dragging, drag delta / distance, slap on snap-back).
/// Without one, call <see cref="SetParameters"/> yourself each frame.
/// <para>
/// <see cref="Voice"/> picks the sound: <see cref="VoiceStyle.Original"/> is the recovered patch exactly as the
/// original game played it; <see cref="VoiceStyle.Terry"/> adds Terry's vocal grunts (<see cref="GruntVoice"/>);
/// <see cref="VoiceStyle.Variation"/> is a subtly varied original. The original patch code is shared and never modified.
/// </para>
/// </summary>
public sealed class FaceAudio : Component
{
public enum VoiceStyle
{
/// <summary>The original game's audio, unchanged.</summary>
Original,
/// <summary>The original patch plus Terry's grunts: "hmph" when stretched hard, "oof" when squished.</summary>
Terry,
/// <summary>
/// The original patch, subtly varied: pitched up a little, a slightly different mix, and a small random
/// tone change on every grab so no two squishes sound quite the same.
/// </summary>
Variation,
}
/// <summary>Which voice to play. Switch to Original for the untouched original audio.</summary>
[Property] public VoiceStyle Voice { get; set; } = VoiceStyle.Terry;
/// <summary>Variation voice: playback pitch (1 = original).</summary>
[Property, Range( 0.8f, 1.25f ), ShowIf( nameof( Voice ), VoiceStyle.Variation )] public float VariationPitch { get; set; } = 1.08f;
/// <summary>Variation voice: largest random tone change applied per grab (0 = none).</summary>
[Property, Range( 0f, 0.6f ), ShowIf( nameof( Voice ), VoiceStyle.Variation )] public float VariationSpread { get; set; } = 0.35f;
/// <summary>Terry voice: loudness of the grunts.</summary>
[Property, Range( 0f, 2f ), ShowIf( nameof( Voice ), VoiceStyle.Terry )] public float GruntLevel { get; set; } = 1f;
/// <summary>Terry voice: pitch of his voice in Hz (lower = deeper).</summary>
[Property, Range( 60f, 200f ), ShowIf( nameof( Voice ), VoiceStyle.Terry )] public float GruntPitch { get; set; } = 98f;
/// <summary>Output volume of the stream (the patch's own master volume is applied on top).</summary>
[Property, Range( 0, 2 )] public float Volume { get; set; } = 1f;
/// <summary>How far ahead of playback the synth renders, in seconds.</summary>
[Property, Range( 0.02f, 0.25f )] public float BufferAhead { get; set; } = 0.12f;
/// <summary>Read parameters from a sibling <see cref="SquishyFace"/> automatically.</summary>
[Property] public bool FollowFace { get; set; } = true;
/// <summary>The synth instance (null until the component starts).</summary>
public FaceSynth Synth { get; private set; }
const int Channels = 2;
SoundStream _stream;
SoundHandle _handle;
float[] _left = Array.Empty<float>();
float[] _right = Array.Empty<float>();
short[] _pcm = Array.Empty<short>();
SquishyFace _face;
bool _slapPending;
readonly GruntVoice _grunt = new();
VoiceStyle? _mixFor;
bool _wasDragging;
float _grabTone;
readonly Random _random = new();
protected override void OnEnabled()
{
Synth = new FaceSynth();
// Same start-up messages as the original game (ie.prototype.start); ApplyMix sets the volumes.
ApplyMix();
Synth.SetParameter( "init", 500f );
_stream = new SoundStream( FaceSynth.SampleRate, Channels );
_handle = _stream.Play( Volume, 1f );
if ( _handle is not null && _handle.IsValid )
{
_handle.SpacialBlend = 0f;
_handle.ListenLocal = true;
}
// Prime the stream so playback starts with a full buffer.
Render( (int)(BufferAhead * FaceSynth.SampleRate) );
}
/// <summary>Sends the voice's mix levels (the original game's values unless Variation is selected).</summary>
void ApplyMix()
{
bool variation = Voice == VoiceStyle.Variation;
Synth.SetParameter( "vol_gloop", variation ? 0.98f : 0.8f );
Synth.SetParameter( "vol_master", 0.6f );
Synth.SetParameter( "vol_slap", variation ? 0.52f : 0.4f );
Synth.SetParameter( "vol_slosh", variation ? 0.5f : 0.66f );
Synth.SetParameter( "vol_stretch", 0.7f );
_mixFor = Voice;
}
protected override void OnDisabled()
{
if ( _handle is not null && _handle.IsValid ) _handle.Stop( 0f );
_handle = null;
_stream?.Dispose();
_stream = null;
Synth = null;
if ( _face is not null ) _face.Slapped -= OnSlapped;
_face = null;
}
protected override void OnUpdate()
{
if ( Synth is null || _stream is null ) return;
if ( _mixFor != Voice ) ApplyMix();
if ( _handle is not null && _handle.IsValid )
{
_handle.Volume = Volume;
_handle.Pitch = Voice == VoiceStyle.Variation ? VariationPitch : 1f;
}
if ( FollowFace ) SendFaceParameters();
int target = (int)(BufferAhead * FaceSynth.SampleRate);
int queued = _stream.QueuedSampleCount / Channels;
if ( queued < target ) Render( target - queued );
}
/// <summary>
/// Sends one frame of parameters, the way the original game did: values with |x| < 1e-4 are sent as 0 and
/// <paramref name="slap"/> sends a one-shot slap = 1.
/// </summary>
public void SetParameters( float volume, float area, bool dragging, float dx, float dy, float dz, float dragDistance, bool slap )
{
if ( Synth is null ) return;
if ( Voice == VoiceStyle.Terry )
{
_grunt.BasePitch = GruntPitch;
_grunt.Update( dragging, dragDistance, volume, slap, Time.Delta );
}
else if ( Voice == VoiceStyle.Variation )
{
// A new small tone offset for every grab, held until the next one.
if ( dragging && !_wasDragging ) _grabTone = ((float)_random.NextDouble() * 2f - 1f) * VariationSpread;
area += _grabTone;
}
_wasDragging = dragging;
Send( "volume", volume );
Send( "area", area );
Send( "dragging", dragging ? 1f : 0f );
if ( slap ) Send( "slap", 1f );
Send( "drag_delta_x", dx );
Send( "drag_delta_y", dy );
Send( "drag_delta_z", dz );
Send( "drag_distance", dragDistance );
}
void Send( string name, float value )
{
Synth.SetParameter( name, MathF.Abs( value ) < 1e-4f ? 0f : value );
}
void OnSlapped() => _slapPending = true;
void SendFaceParameters()
{
if ( _face is null )
{
_face = Components.Get<SquishyFace>();
if ( _face is null ) return;
_face.Slapped += OnSlapped;
}
var sim = _face.Sim;
if ( sim is null ) return;
// Drag delta: the grabbed triangle's first vertex relative to its rest position.
float dx = 0, dy = 0, dz = 0;
int dragTri = _face.DraggingTriangle;
if ( dragTri != -1 )
{
var d = sim.Pos[sim.Indices[dragTri * 3]] - _face.RestPosition( dragTri );
dx = d.X;
dy = d.Y;
dz = d.Z;
}
bool slap = _slapPending;
_slapPending = false;
SetParameters( _face.AudioVolume, _face.AudioArea, dragTri != -1, dx, dy, dz,
MathF.Sqrt( dx * dx + dy * dy + dz * dz ), slap );
}
void Render( int frames )
{
if ( frames <= 0 ) return;
int max = _stream.MaxWriteSampleCount / Channels;
if ( max > 0 && frames > max ) frames = max;
if ( _left.Length < frames )
{
_left = new float[frames];
_right = new float[frames];
_pcm = new short[frames * Channels];
}
Synth.Process( _left.AsSpan( 0, frames ), _right.AsSpan( 0, frames ) );
if ( Voice == VoiceStyle.Terry )
{
_grunt.Level = GruntLevel;
_grunt.MixInto( _left.AsSpan( 0, frames ), _right.AsSpan( 0, frames ) );
// Gentle limiter so the extra layer can't clip (near-linear at normal levels).
for ( int i = 0; i < frames; i++ )
{
_left[i] = MathF.Tanh( _left[i] );
_right[i] = MathF.Tanh( _right[i] );
}
}
for ( int i = 0; i < frames; i++ )
{
_pcm[i * 2] = ToPcm( _left[i] );
_pcm[i * 2 + 1] = ToPcm( _right[i] );
}
_stream.WriteData( _pcm.AsSpan( 0, frames * Channels ) );
}
static short ToPcm( float x )
{
float s = x * 32767f;
if ( s > 32767f ) s = 32767f;
else if ( s < -32768f ) s = -32768f;
return (short)s;
}
}