Audio/GruntVoice.cs
using System;

namespace SquishyTerry.Audio;

/// <summary>
/// Terry's vocal reactions, mixed over <see cref="FaceSynth"/>: a strained "hmph" when he's stretched hard
/// (pitch rising the harder you pull) and an "oof" when he's squished or snaps back.
/// <para>
/// Synthesised with a simple source-filter model: a buzzy glottal pulse (with a little jitter and breath noise)
/// through three formant resonators that shape the vowel, plus short noise bursts for the "h" and "ph".
/// </para>
/// Pure C# (no s&amp;box types) so it can be rendered and auditioned offline.
/// </summary>
public sealed class GruntVoice
{
	const float SampleRate = FaceSynth.SampleRate;
	const float TwoPi = MathF.PI * 2f;
	const float RestVolume = 1.884478f;

	/// <summary>Stretch distances (head units) that each trigger a louder, higher "hmph".</summary>
	static readonly float[] StretchLevels = { 0.3f, 0.55f, 0.8f, 1.05f };

	/// <summary>Overall level.</summary>
	public float Level { get; set; } = 1f;

	/// <summary>Terry's speaking pitch in Hz (a lowish, dopey voice).</summary>
	public float BasePitch { get; set; } = 98f;

	enum Kind { Hmph, Oof }

	sealed class Grunt
	{
		public Kind Kind;
		public float Time, Duration;
		public float PitchStart, PitchEnd;
		public float Loudness;
		// Formants (Hz) at the start and end of the grunt, with relative gains.
		public float F1a, F1b, F2a, F2b, F3;
		public float G2, G3;
	}

	Grunt _grunt;
	int _stretchLevel;
	float _maxStretch;
	bool _wasDragging;
	float _cooldown;
	float _sinceOof = 10f;
	bool _squished;

	// Synthesis state.
	float _phase, _tilt;
	uint _noiseSeed = 22222;
	readonly Formant _f1 = new(), _f2 = new(), _f3 = new(), _breath = new();
	int _coefCountdown;

	/// <summary>Feeds one game frame of gesture state (call before rendering that frame's audio).</summary>
	/// <param name="dragging">A point is being held.</param>
	/// <param name="dragDistance">How far the held point is from rest (head units).</param>
	/// <param name="volume">Head volume normalised to the original face's rest volume.</param>
	/// <param name="slap">The released point just snapped back.</param>
	/// <param name="frameSeconds">Time since the previous call.</param>
	public void Update( bool dragging, float dragDistance, float volume, bool slap, float frameSeconds )
	{
		_cooldown -= frameSeconds;
		_sinceOof += frameSeconds;

		if ( dragging )
		{
			if ( !_wasDragging ) { _stretchLevel = 0; _maxStretch = 0; }
			_maxStretch = MathF.Max( _maxStretch, dragDistance );

			// Climbing past the next stretch level: a higher, louder "hmph".
			if ( _stretchLevel < StretchLevels.Length && dragDistance > StretchLevels[_stretchLevel] && _cooldown <= 0 )
			{
				Hmph( _stretchLevel );
				_stretchLevel++;
				_cooldown = 0.28f;
			}
			// Easing back lets the same level trigger again.
			while ( _stretchLevel > 0 && dragDistance < StretchLevels[_stretchLevel - 1] - 0.12f )
				_stretchLevel--;
		}

		// Snapping back after a real stretch knocks the wind out of him.
		if ( slap && _maxStretch > 0.3f )
		{
			Oof( MathF.Min( _maxStretch, 1.2f ) );
			_maxStretch = 0;
		}

		// Squished hard (volume well below rest) without having just said "oof".
		bool squished = volume < RestVolume * 0.9f;
		if ( squished && !_squished && _sinceOof > 0.6f && _cooldown <= 0 )
			Oof( (RestVolume - volume) / RestVolume * 4f );
		_squished = squished;

		_wasDragging = dragging;
	}

	void Hmph( int level )
	{
		// Each level up is 2.5 semitones higher and louder.
		float semis = level * 2.5f;
		float f0 = BasePitch * MathF.Pow( 2f, semis / 12f );
		_grunt = new Grunt
		{
			Kind = Kind.Hmph,
			Duration = 0.34f + level * 0.04f,
			PitchStart = f0 * 0.94f,
			PitchEnd = f0 * 1.06f,
			Loudness = 0.55f + level * 0.15f,
			// Closed-mouth "mm" opening slightly to "uh" as the strain peaks.
			F1a = 260f, F1b = 480f, F2a = 1100f, F2b = 1150f, F3 = 2400f,
			G2 = 0.35f, G3 = 0.12f,
		};
	}

	void Oof( float strength )
	{
		float s = Math.Clamp( strength, 0.3f, 1.2f );
		_grunt = new Grunt
		{
			Kind = Kind.Oof,
			Duration = 0.26f + 0.08f * s,
			PitchStart = BasePitch * (1.18f + 0.15f * s),
			PitchEnd = BasePitch * 0.8f,
			Loudness = 0.6f + 0.35f * s,
			// Open "uh" rounding into "oo".
			F1a = 620f, F1b = 330f, F2a = 1150f, F2b = 780f, F3 = 2450f,
			G2 = 0.5f, G3 = 0.15f,
		};
		_sinceOof = 0;
		_cooldown = 0.25f;
	}

	/// <summary>Adds the voice into existing stereo buffers (centred).</summary>
	public void MixInto( Span<float> left, Span<float> right )
	{
		var g = _grunt;
		if ( g is null ) return;

		int n = Math.Min( left.Length, right.Length );
		float dt = 1f / SampleRate;
		float tiltCoef = 1f - MathF.Exp( -TwoPi * 1400f * dt );

		for ( int i = 0; i < n; i++ )
		{
			if ( g.Time >= g.Duration )
			{
				_grunt = null;
				return;
			}

			float x = g.Time / g.Duration;

			// Retune the formants every 64 samples as the vowel moves.
			if ( --_coefCountdown <= 0 )
			{
				_coefCountdown = 64;
				float shape = g.Kind == Kind.Hmph ? MathF.Min( x * 2.2f, 1f ) : x;
				_f1.Tune( g.F1a + (g.F1b - g.F1a) * shape, 90f );
				_f2.Tune( g.F2a + (g.F2b - g.F2a) * shape, 120f );
				_f3.Tune( g.F3, 180f );
				_breath.Tune( g.Kind == Kind.Hmph ? 1500f : 900f, 600f );
			}

			// Glottal buzz: sawtooth with a little jitter, softened by a spectral tilt.
			float f0 = g.PitchStart + (g.PitchEnd - g.PitchStart) * x;
			f0 *= 1f + 0.012f * (Noise() - 0.5f);
			_phase += f0 * dt;
			if ( _phase >= 1f ) _phase -= 1f;
			float buzz = 1f - 2f * _phase;
			_tilt += (buzz - _tilt) * tiltCoef;
			float source = _tilt + 0.06f * (Noise() * 2f - 1f);

			float voiced = _f1.Process( source ) + g.G2 * _f2.Process( source ) + g.G3 * _f3.Process( source );

			// Envelopes: quick attack, rounded release.
			float attack = MathF.Min( g.Time / 0.012f, 1f );
			float release = x > 0.6f ? MathF.Max( 0f, 1f - (x - 0.6f) / 0.4f ) : 1f;
			float env = attack * release * release;

			// Breath: an "h" at the start of both, and a "ph" puff at the end of "hmph".
			float breathEnv = MathF.Max( 0f, 1f - g.Time / 0.035f ) * 0.5f;
			if ( g.Kind == Kind.Hmph && x > 0.82f ) breathEnv += (1f - x) / 0.18f * 0.8f;
			float breath = _breath.Process( Noise() * 2f - 1f ) * breathEnv;

			// The "hmph" keeps the mouth closed (hum) until the puff; mute the voicing there.
			float voicing = g.Kind == Kind.Hmph && x > 0.82f ? 0.25f : 1f;

			float sample = (voiced * env * voicing + breath) * g.Loudness * 0.75f * Level;
			left[i] += sample;
			right[i] += sample;

			g.Time += dt;
		}
	}

	float Noise()
	{
		_noiseSeed = _noiseSeed * 1664525u + 1013904223u;
		return (_noiseSeed >> 8) / 16777216f;
	}

	/// <summary>Two-pole band-pass resonator (constant 0 dB peak gain).</summary>
	sealed class Formant
	{
		float _b0, _b2, _a1, _a2;
		float _x1, _x2, _y1, _y2;

		public void Tune( float freq, float bandwidth )
		{
			float w0 = TwoPi * freq / SampleRate;
			float q = freq / bandwidth;
			float alpha = MathF.Sin( w0 ) / (2f * q);
			float a0 = 1f + alpha;
			_b0 = alpha / a0;
			_b2 = -alpha / a0;
			_a1 = -2f * MathF.Cos( w0 ) / a0;
			_a2 = (1f - alpha) / a0;
		}

		public float Process( float x )
		{
			float y = _b0 * x + _b2 * _x2 - _a1 * _y1 - _a2 * _y2;
			_x2 = _x1; _x1 = x;
			_y2 = _y1; _y1 = y;
			return y;
		}
	}
}