Last active
September 27, 2021 15:09
-
-
Save profexorgeek/da45a407fa8ec1eded1af9044cfb2885 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using FlatRedBall; | |
using FlatRedBall.Audio; | |
using FlatRedBall.Content; | |
using Microsoft.Xna.Framework; | |
using Microsoft.Xna.Framework.Audio; | |
using Microsoft.Xna.Framework.Media; | |
using NarfoxGameTools.Extensions; | |
using System; | |
using System.Collections.Generic; | |
using System.IO; | |
namespace NarfoxGameTools.Services | |
{ | |
/// <summary> | |
/// This class wraps FlatRedBall and MonoGame audio utilities | |
/// to provide more advanced functionality like positioned audio | |
/// </summary> | |
public class SoundService | |
{ | |
/// <summary> | |
/// A struct that represents a queued request | |
/// to play a sound. | |
/// </summary> | |
protected struct SoundRequest | |
{ | |
public string Name; | |
public float Volume; | |
public float Pitch; | |
public float Pan; | |
public double TimeRequested; | |
public double Duration; | |
} | |
// how much the pitch is allowed to vary | |
// in sound requests | |
const float PitchVariance = 0.25f; | |
static SoundService instance; | |
List<SoundRequest> soundQueue = new List<SoundRequest>(); | |
Dictionary<SoundEffectInstance, PositionedObject> ownedInstances = new Dictionary<SoundEffectInstance, PositionedObject>(); | |
ContentManager contentManager; | |
float musicVolume = 1; | |
bool initialized = false; | |
PositionedObject target; | |
/// <summary> | |
/// Property exposing this class as a singleton | |
/// </summary> | |
public static SoundService Instance | |
{ | |
get | |
{ | |
if (instance == null) | |
{ | |
instance = new SoundService(); | |
} | |
return instance; | |
} | |
} | |
/// <summary> | |
/// The maximum distance a sound can be heard, | |
/// usually in pixels for 2D pixelart games. | |
/// Positioned sound will attenuate linearly | |
/// over this distance. | |
/// </summary> | |
public float VolumeMaxDistance { get; set; } | |
/// <summary> | |
/// The target "listener" to use when determining audio | |
/// position. This is usually the camera but may need | |
/// to be a different target if the camera interpolates | |
/// to a position over time. | |
/// </summary> | |
public PositionedObject Target | |
{ | |
get | |
{ | |
if(target == null) | |
{ | |
target = Camera.Main; | |
} | |
return target; | |
} | |
set | |
{ | |
target = value; | |
if(target == null) | |
{ | |
target = Camera.Main; | |
} | |
} | |
} | |
/// <summary> | |
/// The maximum number of sounds to play at the same time. This may need to be | |
/// set differently for mobile or lowspec devices that have low limits on | |
/// simultaneous sounds | |
/// </summary> | |
public float MaxConcurrentSounds { get; set; } = 32f; | |
/// <summary> | |
/// The number of sounds in the queue | |
/// </summary> | |
public float CurrentlyPlayingSounds | |
{ | |
get | |
{ | |
return soundQueue.Count; | |
} | |
} | |
/// <summary> | |
/// The name of the content manager responsible for loading and caching sounds | |
/// </summary> | |
public string ContentManagerName | |
{ | |
get | |
{ | |
return contentManager.Name; | |
} | |
set | |
{ | |
var name = value; | |
contentManager = FlatRedBallServices.GetContentManagerByName(name); | |
if (contentManager == null) | |
{ | |
throw new Exception($"Bad content manager name: {name}"); | |
} | |
} | |
} | |
/// <summary> | |
/// The folder where sounds are located. | |
/// </summary> | |
public string SoundFolder { get; set; } = @"Content/GlobalContent/Sounds"; | |
/// <summary> | |
/// The folder where music is located. | |
/// </summary> | |
public string MusicFolder { get; set; } = @"Content/GlobalContent/Music"; | |
/// <summary> | |
/// The mix volume of the music: how loud it should be in the mix, from 0 - 1 | |
/// </summary> | |
public float MusicMixVolume { get; set; } = 1f; | |
/// <summary> | |
/// The mix volume of the sound: how loud it should be in the mix, from 0 - 1 | |
/// </summary> | |
public float SoundMixVolume { get; set; } = 1f; | |
/// <summary> | |
/// The volume of music in the game, this is usually set by the user in some type | |
/// of settings menu | |
/// </summary> | |
public float MusicVolume | |
{ | |
get | |
{ | |
return musicVolume; | |
} | |
set | |
{ | |
musicVolume = value; | |
MediaPlayer.Volume = CalcMusicVolume; | |
} | |
} | |
/// <summary> | |
/// The volume of sound in the game, this is usually set by the user in some tyupe | |
/// of settings menu | |
/// </summary> | |
public float SoundVolume { get; set; } = 1f; | |
/// <summary> | |
/// The calculated total volume of music, a product of the mix volume and user volume | |
/// </summary> | |
float CalcMusicVolume => MusicVolume * MusicMixVolume; | |
/// <summary> | |
/// The calculated total volume of sound, a product of the mix volume and user volume | |
/// </summary> | |
float CalcSoundVolume => SoundVolume * SoundMixVolume; | |
/// <summary> | |
/// Whether or not sound will play. If true, no sound will play at all | |
/// </summary> | |
public bool IsMuted { get; set; } = false; | |
/// <summary> | |
/// Protected constructor to enforce singleton pattern | |
/// </summary> | |
protected SoundService() { } | |
/// <summary> | |
/// Initializes the sound service. This must be called before attempting | |
/// to play sounds or otherwise use the SoundService. This should be called | |
/// in the Game1 class in FlatRedBall during Initialization | |
/// </summary> | |
/// <param name="managerName">The name of the manager to use, defaults to the GlobalContentManager</param> | |
public void Initialize(string managerName = null) | |
{ | |
ContentManagerName = managerName ?? FlatRedBallServices.GlobalContentManager; | |
// use camera for default volume distance and target | |
VolumeMaxDistance = Camera.Main.AbsoluteRightXEdgeAt(0); | |
initialized = true; | |
} | |
/// <summary> | |
/// This must be called in the game loop. This processes the queue of requested | |
/// sounds every frame | |
/// </summary> | |
public void Update() | |
{ | |
if(!initialized) | |
{ | |
throw new Exception("Attempted to update SoundService before calling Initialize()!"); | |
} | |
// play sounds | |
for (int i = soundQueue.Count - 1; i > -1; i--) | |
{ | |
var req = soundQueue[i]; | |
if(TimeManager.CurrentTime - req.TimeRequested > req.Duration) | |
{ | |
soundQueue.Remove(req); | |
} | |
} | |
// manage named instances | |
foreach (var kvp in ownedInstances) | |
{ | |
if(!kvp.Key.IsDisposed && kvp.Value != null) | |
{ | |
kvp.Key.Pan = GetPanForPosition(kvp.Value.Position); | |
kvp.Key.Volume = IsMuted ? 0 : GetVolumeForPosition(kvp.Value.Position); | |
} | |
} | |
} | |
/// <summary> | |
/// Fire-and-forget method to play a single sound effect. Passing the Position will pan | |
/// and attenuate the sound based on the Target position. This method doesn't | |
/// keep a handle to the sound effect and so effects played this way can not be | |
/// stopped or altered once started. | |
/// </summary> | |
/// <param name="effectName">The name of the effect, which will be resolved from the base directories</param> | |
/// <param name="position">The position of the effect, used to attenuate and pan the sound</param> | |
/// <param name="randomizePitch">Whether or not to randomize pitch, defaults to true</param> | |
public void RequestPlayEffect(string effectName, Vector3? position = null, bool randomizePitch = true) | |
{ | |
if(!initialized) | |
{ | |
throw new Exception("Attempted to play effect before initializing the SoundService."); | |
} | |
// EARLY OUT: null name | |
if(string.IsNullOrWhiteSpace(effectName)) | |
{ | |
LogService.Log.Debug($"Empty sound name requested!"); | |
return; | |
} | |
var pitch = randomizePitch ? RandomService.Random.InRange(-PitchVariance, PitchVariance) : 0f; | |
var request = new SoundRequest | |
{ | |
Name = effectName, | |
Volume = GetVolumeForPosition(position), | |
Pan = GetPanForPosition(position), | |
Pitch = pitch, | |
TimeRequested = TimeManager.CurrentScreenTime, | |
}; | |
PlaySound(request); | |
} | |
/// <summary> | |
/// Fire-and-forget method to play a single sound effect with specific volume, pan, and pitch | |
/// </summary> | |
/// <param name="effectName">The name of the effect, which will be resolved from the base directories</param> | |
/// <param name="volume">The volume of the effect from 0 to 1</param> | |
/// <param name="pan">The pan of the effect from -1 to 1</param> | |
/// <param name="pitch">The pitch of the effect</param> | |
public void RequestPlayEffect(string effectName, float volume, float pan, float pitch) | |
{ | |
if (!initialized) | |
{ | |
throw new Exception("Attempted to play effect before initializing the SoundService."); | |
} | |
// EARLY OUT: null name | |
if (string.IsNullOrWhiteSpace(effectName)) | |
{ | |
LogService.Log.Debug($"Empty sound name requested!"); | |
return; | |
} | |
var request = new SoundRequest | |
{ | |
Name = effectName, | |
Volume = volume, | |
Pan = pan, | |
Pitch = pitch, | |
TimeRequested = TimeManager.CurrentScreenTime, | |
}; | |
PlaySound(request); | |
} | |
/// <summary> | |
/// Plays a song and can force a restart or loop | |
/// </summary> | |
/// <param name="song">The song instance to play</param> | |
/// <param name="loop">Whether or not to loop the song</param> | |
/// <param name="forceRestart">Whether or not to force a restart if a song is already playing</param> | |
public void RequestPlaySong(Song song, bool loop = true, bool forceRestart = false) | |
{ | |
var current = AudioManager.CurrentlyPlayingSong; | |
// if we have no song, or our current song has a different name, or we're forcing | |
// a song restart - stop other music and start playing the new song | |
if (current == null || current.Name != song.Name || forceRestart) | |
{ | |
AudioManager.StopSong(); | |
MediaPlayer.Volume = CalcMusicVolume; | |
MediaPlayer.IsRepeating = loop; | |
AudioManager.PlaySong(song, true, true); | |
} | |
} | |
/// <summary> | |
/// Gets a sound effect instance with a handle. This is used for sounds that need to be | |
/// updated over time to stop and start or play at a specific pitch. Any sound requested | |
/// this way should be released when the requestor is done with the sound using the | |
/// UnloadOwnedInstance method | |
/// </summary> | |
/// <param name="effectName"></param> | |
/// <param name="requestor"></param> | |
/// <param name="playImmediately"></param> | |
/// <param name="isLooped"></param> | |
/// <returns>A SoundEffectInstance</returns> | |
public SoundEffectInstance GetOwnedInstance(string effectName, PositionedObject requestor = null, bool playImmediately = false, bool isLooped = true) | |
{ | |
SoundEffect effect = GetEffect(effectName); | |
SoundEffectInstance instance = null; | |
if (effect != null) | |
{ | |
instance = effect.CreateInstance(); | |
// volume defaults to zero because the position may not be taken | |
// into account until next frame and we don't want audio to | |
// pop in and go quiet. This allows requestors to immediately | |
// play | |
instance.Volume = 0; | |
instance.Pan = 0; | |
instance.IsLooped = isLooped; | |
if(playImmediately) | |
{ | |
instance.Play(); | |
} | |
if(requestor != null) | |
{ | |
ownedInstances.Add(instance, requestor); | |
} | |
} | |
return instance; | |
} | |
/// <summary> | |
/// Unloads an owned SoundEffectInstance obtained using GetOwnedInstance | |
/// </summary> | |
/// <param name="instance">The instance to unload</param> | |
public void UnloadOwnedInstance(SoundEffectInstance instance) | |
{ | |
if(ownedInstances.ContainsKey(instance)) | |
{ | |
instance.Stop(); | |
ownedInstances.Remove(instance); | |
} | |
} | |
/// <summary> | |
/// Unloads all owned instances. This is usually called when unloading | |
/// a screen to make sure all owned instances are released. | |
/// </summary> | |
public void UnloadAllOwnedInstances() | |
{ | |
ownedInstances.Clear(); | |
} | |
/// <summary> | |
/// Calculates the volume of a sound based on its distance | |
/// from the Target listener. Returns 1 if provided with a | |
/// bad position | |
/// </summary> | |
/// <param name="nullablePosition">The position to use for calculation</param> | |
/// <returns>A float representing the volume from 0 to 1</returns> | |
protected float GetVolumeForPosition(Vector3? nullablePosition) | |
{ | |
// EARLY OUT: null position | |
if (IsMuted) | |
{ | |
return 0f; | |
} | |
// assume max volume to start, if no position was | |
// passed, this will be the default | |
var volume = 1f; | |
// if we got a position, calculate the sound based | |
// on max distance | |
if(nullablePosition != null) | |
{ | |
var position = nullablePosition.Value; | |
var dist = Target.DistanceTo(position.X, position.Y); | |
if (dist < VolumeMaxDistance) | |
{ | |
volume = 1f - (dist / VolumeMaxDistance); | |
} | |
else | |
{ | |
volume = 0f; | |
} | |
} | |
volume *= CalcSoundVolume; | |
return volume; | |
} | |
/// <summary> | |
/// Calculates the pan for a sound based on its position to | |
/// left or right of the Target listener | |
/// </summary> | |
/// <param name="nullablePosition">The position to use for calculation</param> | |
/// <returns>A float from -1 to 1</returns> | |
protected float GetPanForPosition(Vector3? nullablePosition) | |
{ | |
// EARLY OUT: null position | |
if (nullablePosition == null) | |
{ | |
return 0f; | |
} | |
var position = nullablePosition.Value; | |
var deltaX = position.X - Target.X; | |
var percent = deltaX / VolumeMaxDistance; | |
return percent.Clamp(-1f, 1f); | |
} | |
/// <summary> | |
/// Plays a sound request object, usually called during Update when | |
/// processing the sound queue | |
/// </summary> | |
/// <param name="request">The sound request object to play</param> | |
protected void PlaySound(SoundRequest request) | |
{ | |
var effect = GetEffect(request.Name); | |
//var effectLeft = GetEffect(request.Name); | |
//var effectRight = GetEffect(request.Name); | |
if (effect == null) | |
{ | |
LogService.Log.Debug($"Bad effect requested: {request.Name}!"); | |
} | |
else if (IsMuted == false && CurrentlyPlayingSounds < MaxConcurrentSounds) | |
{ | |
request.Duration = effect.Duration.TotalMilliseconds / 1000f; | |
request.TimeRequested = TimeManager.CurrentTime; | |
soundQueue.Add(request); | |
try | |
{ | |
// NOTE: pan does not seem to be working right. Did thorough | |
// investigation and it appears to be an issue with the | |
// underlying default audio engine in monogame | |
// see: | |
// https://github.com/MonoGame/MonoGame/issues/6876 | |
// https://github.com/MonoGame/MonoGame/issues/6543 | |
// https://github.com/MonoGame/MonoGame/issues/5739 | |
// One potential hack is to play two sounds at once and fake panning: | |
//float panVolumeCompensation = // figure out the calc for this; | |
//var leftVolume = (1 - request.Pan).Clamp(0, 1) * request.Volume * panVolumeCompensation; | |
//var rightVolume = (1 + request.Pan).Clamp(0, 1) * request.Volume * panVolumeCompensation; | |
//effectLeft.Play(leftVolume, request.Pitch, -1); | |
//effectRight.Play(rightVolume, request.Pitch, 1); | |
// This is how the sound should actually be played | |
effect.Play(request.Volume, request.Pitch, request.Pan); | |
} | |
catch (Exception e) | |
{ | |
LogService.Log.Error(e.Message); | |
} | |
} | |
else | |
{ | |
LogService.Log.Debug($"Too many sounds requested {CurrentlyPlayingSounds}/{MaxConcurrentSounds}"); | |
} | |
} | |
/// <summary> | |
/// Loads a sound effect by name from the content manager | |
/// </summary> | |
/// <param name="name">The filename of the effect</param> | |
/// <returns>A SoundEffect instance</returns> | |
protected SoundEffect GetEffect(string name) | |
{ | |
var barename = Path.GetFileNameWithoutExtension(name); | |
var path = Path.Combine(SoundFolder, barename); | |
var effect = contentManager.Load<SoundEffect>(path); | |
return effect; | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment