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| &lt; 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;
	}
}