Audio/MusicDirector.cs

Component that manages continuous music and ambience beds and crossfades between a calm and in-run music layer. It keeps both music layers playing, adjusts their volumes based on game state and player settings, and retriggers looping beds when they finish.

File Access
namespace Coilgarden;

/// <summary>
/// Keeps the music and ambient beds running, and crossfades between the two music layers.
/// <para>
/// <b>Looping.</b> s&amp;box has no "loop this SoundEvent" flag, so a bed is looped by
/// re-triggering it as it finishes. The music is written as a whole number of bars with its
/// partials at whole cycle counts across the buffer, so the end meets the start exactly and a
/// re-trigger is indistinguishable from a looping asset - see <c>Tools/generate_audio.py</c>,
/// which asserts the seam is quiet before writing the file.
/// </para>
/// <para>
/// <b>Both layers always play.</b> They are mixed, never switched. Starting a track is audible
/// and stopping one mid-phrase clicks, so both run continuously from the moment the game loads
/// and only their volumes move. Because they share the same harmony, any mix of the two is
/// consonant and a transition never sounds like one.
/// </para>
/// </summary>
public sealed class MusicDirector : Component
{
	[Property] public GameSession Session { get; set; }

	/// <summary>Turn the beds off entirely, to check the game still reads on effects alone.</summary>
	[Property] public bool Enable { get; set; } = true;

	/// <summary>
	/// Live off the player's settings rather than an authored <c>[Property]</c>, for the same
	/// reason as <see cref="GameAudio.SfxVolume"/>: one slider, one meaning. Falls back to the
	/// designed default in edit mode, where there is no session to ask.
	/// </summary>
	public float MusicVolume => Session?.Settings?.MusicVolume ?? GameConfig.MusicVolume;

	public float AmbienceVolume => Session?.Settings?.AmbienceVolume ?? GameConfig.AmbienceVolume;

	private float MasterVolume => Session?.Settings?.MasterVolume ?? GameConfig.MasterVolume;

	private SoundHandle calm;
	private SoundHandle play;
	private SoundHandle ambience;

	/// <summary>How far towards the in-run bed the mix currently is, 0 to 1.</summary>
	private float blend;

	protected override void OnEnabled()
	{
		Session ??= Scene.GetAllComponents<GameSession>().FirstOrDefault();

		blend = 0f;
	}

	protected override void OnDisabled() => StopAll();

	protected override void OnUpdate()
	{
		if ( !Enable )
		{
			StopAll();
			return;
		}

		// The in-run bed is wanted while a run is actually advancing. A pause drifts back to the
		// calm layer, which is a more pleasant way to signal "stopped" than ducking the volume.
		var wantPlay = Session is not null && Session.State == GameState.Playing;
		var target = wantPlay ? 1f : 0f;

		blend = Approach( blend, target, GameConfig.MusicCrossfade );

		Keep( ref calm, GameSounds.MusicCalm, MusicVolume * (1f - blend) );
		Keep( ref play, GameSounds.MusicPlay, MusicVolume * blend );
		Keep( ref ambience, GameSounds.Ambience, AmbienceVolume );
	}

	/// <summary>
	/// Moves towards a target at a rate set by a crossfade duration, rather than by a lerp
	/// factor. Framerate-independent, and the duration is a number a designer can reason about.
	/// </summary>
	private static float Approach( float current, float target, float duration )
	{
		if ( duration <= 0f ) return target;

		var step = Time.Delta / duration;

		return current < target
			? MathF.Min( current + step, target )
			: MathF.Max( current - step, target );
	}

	/// <summary>
	/// Starts a bed if it is not playing, restarts it when it finishes, and keeps its volume up
	/// to date.
	/// <para>
	/// A bed whose volume has reached zero is left playing rather than stopped. Stopping and
	/// restarting it would put an audible seam at exactly the moment it starts fading back in,
	/// and a silent voice costs nothing.
	/// </para>
	/// </summary>
	private void Keep( ref SoundHandle handle, string sound, float volume )
	{
		var level = volume * MasterVolume;

		if ( handle is null || handle.IsStopped || handle.Finished )
		{
			handle = Sound.Play( sound );

			if ( handle is null ) return;
		}

		handle.Volume = MathF.Max( 0f, level );
	}

	private void StopAll()
	{
		calm?.Stop();
		play?.Stop();
		ambience?.Stop();

		calm = null;
		play = null;
		ambience = null;
	}

	/// <summary>The live mix, for the debug readout.</summary>
	public float Blend => blend;

	public bool CalmPlaying => calm is not null && !calm.IsStopped;

	public bool PlayPlaying => play is not null && !play.IsStopped;

	public bool AmbiencePlaying => ambience is not null && !ambience.IsStopped;
}