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