├── GameSound.cs ├── LICENSE ├── README.md ├── SingletonMonoBehaviour.cs ├── SoundManager-Inspector-example.png └── SoundManager.cs /GameSound.cs: -------------------------------------------------------------------------------- 1 | namespace LeakyAbstraction 2 | { 3 | /// 4 | /// This enum defines the sound types available to play. 5 | /// Each enum value can have AudioClips assigned to it in the SoundManager's Inspector pane. 6 | /// 7 | public enum GameSound 8 | { 9 | // It's advisable to keep 'None' as the first option, since it helps exposing this enum in the Inspector. 10 | // If the first option is already an actual value, then there is no "nothing selected" option. 11 | None, 12 | ThisSound, 13 | ThatSound 14 | // ... 15 | } 16 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Gabor Barat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SoundManager component for Unity3D 2 | ## *With modulation ranges, AudioSource pooling, playing at world coordinates, and even playing while following a Transform* 3 | #### Version information: Tested in Unity 2018.3, requires scripting runtime set to '.Net 4.x equivalent' (uses C# 7 features) 4 | 5 | My take on a light-weight sound manager for Unity3D. Admittedly I haven't looked into any implementations on the Asset Store, etc., because I didn't want to spoil the fun of writing mine from scratch. So this is just a small, relatively simple component with solid core features that should perform reliably and efficiently. 6 | 7 | I've been using it in my project for quite some time, and I'm a satisfied customer, so to speak, so I decided to share it. 8 | 9 | Pro tip: It's totally not spaghetti code. ;) I tried to generally separate the responsibilities, but without creating a confusing amount of classes/files, so I pretty much nested everything into a single class. I think it's a relatively maintainable/extensible design, so you shouldn't have a very hard time adding new features if you wanted. (I also have some ideas for additional features, but those would require some more book-keeping, plus classes where nesting them is not really justifiable.) 10 | 11 | *Note that the comments in the code are a bit excessive if you're an experienced developer. I just tried to help others too to understand it better.* 12 | 13 | **Update1:** I added the capability of **Transform tracking playback** in the form of two new public methods. This means that the `SoundManager` can now be used for moving objects too. For this addition I refactored the class internally. Short testing shows this new feature to be working well, but please inform me of any bugs. 14 | 15 | **Update2:** I encapsulated all debug log messages into a separate class, and added a **logging setting** to the Inspector. So **you can turn off logging for deployed builds** in a way that completely avoids string operations and allocations (since all debug messages are constructed inside this helper class). Available logging settings: `None`, `LogOnlyInEditor` and `LogAlways`. 16 | 17 | **Update3:** I swear I'll stop adding these update sections here. :) Anyhow, I refactored the `SoundManager` class again. Plus I changed the public method signatures. The most visible change is that **the methods now return the sound type in the callback**, so you can route multiple playback finished notifications into a single method, and check there which one was finished. In terms of refactoring, internally the class changed a lot; basically I encapsulated all `AudioSource` handling responsibilities into a separate nested class, and cut out *all* code duplication in the method overloads. 18 | 19 | ## Quick overview of Inspector pane: 20 | 21 | ![SoundManager pane in Inspector](SoundManager-Inspector-example.png) 22 | 23 | ## Rationale 24 | 25 | Pretty much everybody uses some sort of audio or sound manager, from what I'm aware of. But if you're not sure what's the point: 26 | 27 | - ### Playing sounds on destroyed/disabled objects 28 | - If you have an `AudioSource` on a `GameObject`, and you destroy it (or ideally, disable for releasing into the pool), you can't play any sounds on it, since sound playback stops instantly. 29 | - ### Playing sounds with modulated pitch/volume 30 | - Sounds sound the best if you slightly modulate the pitch and volume each time you play them, to make them feel natural. It's messy to do this individually everywhere. 31 | 32 | ## Features 33 | 34 | - ### Customizable random pitch and volume range for each AudioClip 35 | - You can set up the range of random pitch and volume for each audioclip at a centralized location. Then you just simply play the sound by invoking `PlaySound()`, and the pitch and volume will be automatically modulated each time. 36 | 37 | - ### Defining multiple AudioClips for a sound type 38 | - One given sound type can have multiple entries and `AudioClip`s associated to it in the list. The `SoundManager` automatically creates a list internally from all 'sound variations' of a given sound type, and when you invoke `PlaySound()`, it selects one randomly. This is also a rather important part of providing rich and diverse audio experiences. 39 | 40 | - ### Smart, automatic pooling of AudioSources 41 | - You can define how many simultaneous sounds you want to support at startup. When you invoke `PlaySound()`, the `SoundManager` automatically reserves an `AudioSource` from the pool to play the requested sound, waits for the playback to finish, and puts the `AudioSource` back to the pool. No polling involved whatsoever. Coroutine-based operation. No wasteful use of collections; it uses a simple `Stack` the way it's supposed to be used. If it runs out of available `AudioSource`s, it can grow the pool on-demand (if you enable this feature). 42 | 43 | - ### Support for 3D positioned sound playback 44 | - My current game is 2D, so I have no use for this, but I wanted to add this little extra before sharing it. Basically, you can simply use the `PlaySoundPositioned()` method that accepts a `Vector3` position defining where to play the sound. So, for example: 45 | 46 | `SoundManager.Instance.PlaySoundPositioned(GameSound.Death, transform.position)` 47 | 48 | - When the playback is complete, the `AudioSource` will be instantly put back to its original position. There is no expensive reparenting involved; the `SoundManager` simply creates a `GameObject` for each `AudioSource` in the pool at startup, so it can later position them anywhere. 49 | 50 | - ### NEW: Support for Transform tracking sound playback 51 | - After some pondering I decided to add this too. This means being able to use the `SoundManager` in cases when you need a moving `AudioSource` (for a moving `GameObject`). I added two new overloads of the `PlaySoundPositioned()` method which take a `Transform`, and they follow the position of this transform for the entire duration of the playback (and then jump back to origin). Invoking these methods are similarly simple, for example: 52 | 53 | `SoundManager.Instance.PlaySoundPositioned(GameSound.RocketLaunch, transform)` 54 | 55 | - ### Overriding preset pitch and volume 56 | - There is an overload of the `PlaySound()` and `PlaySoundPositioned()` methods that accept two floats which serve as multipliers to pitch and volume. So if you find yourself wanting to play a faster/slower or louder/quieter sound than normal, or play it reverse by using a negative pitch, you can. These multipliers are applied on top of the already randomized pitch and volume, so the sound variation is kept intact. 57 | 58 | - ### Callback when playback is finished 59 | - All versions of the `PlaySound()` and `PlaySoundPositioned()` methods accept an optional `callback` parameter, in case you want to be notified when the playback finishes. 60 | - Additionally, all methods return the `AudioSource` playing your requested sound, so you can monitor it yourself if you want. But don't mess with the playback settings on the returned `AudioSource`, because then the `SoundManager` won't be able to predict the end time of the playback (to release the `AudioSource` to the pool). (It does have built-in safety mechanism for additional waiting, though.) 61 | - If the playback fails for whatever reason, the return value is `null`, so you can actually check if playback was successful. 62 | 63 | - ### Thoroughly commented and documented code 64 | - I added standard XML documentation tags to all public methods, so Visual Studio's IntelliSense can help you understand what do the methods and parameters do. 65 | - Also, the code contains lots of comments, including on all private methods and everywhere where something might not be obvious. I think I went a bit overboard, because I know that many Unity3D users are not that well-versed in programming. 66 | 67 | - ### Generally robust and error-tolerant design 68 | - There are a lot of checks internally for various error cases, and they log intelligible warning messages to the console. Of course you might want to strip out this debug logging, integrate some switchable or injected logging system, or whatever. 69 | 70 | - ### Generally efficient code 71 | - I tried to avoid allocations and losing performance for no good reason. The code properly uses a `Dictionary` for lookups and `Lists` for indexed access, basically doesn't `new` up and throw away anything, etc. 72 | - One thing you might want to look into is the infamous allocation when you use `enum` as `Dictionary` keys. I have no idea if this still happens these days; if so, you can supposedly avoid it by providing a custom comparer for the `enum`. 73 | 74 | ## Usage examples 75 | 76 | Just for illustrative purposes, because it's really obvious and straightforward to use. 77 | 78 | - Playing a 'sound type' **the simplest way possible** (no 3D positioning, perfect for mobile games): 79 | 80 | `SoundManager.Instance.PlaySound(GameSound.Rocket);` 81 | 82 | - Playing a 'sound type' **at a given world position**: 83 | 84 | `SoundManager.Instance.PlaySoundPositioned(GameSound.Rocket, transform.position);` 85 | 86 | - Playing a 'sound type' **at a given world position**, by **overriding pitch and volume** with a multiplier (obviously you can leave out the parameter names :) ): 87 | 88 | `SoundManager.Instance.PlaySoundPositioned(GameSound.Rocket, volumeMultiplier: 0.5f, pitchMultiplier: 0.6f, transform.position);` 89 | 90 | - Playing a 'sound type' **at a given world position**, by **overriding pitch and volume** with a multiplier, and setting a **callback to be invoked when the sound playback finishes** (where *DoAfterPlayback* is a void parameterless method): 91 | 92 | `SoundManager.Instance.PlaySoundPositioned(GameSound.Rocket, volumeMultiplier: 0.5f, pitchMultiplier: 0.6f, transform.position, DoAfterPlayback);` 93 | 94 | (The new `Transform` tracking playback is similarly simple, so I'm not including those examples.) 95 | 96 | *(Note that the callback shown is obviously available as an optional parameter on all of the method overloads, not just on the longest. Also note that there is an overload for setting pitch and volume override for the simple `PlaySound()` method too.)* 97 | 98 | ## Setup 99 | 100 | 1. **Specify your `sound types` by editing the `GameSound` enum.** This means that all the sounds you want to be able to play need to be named in this enum. This obviously has some drawbacks, for example you can't just nilly-willy delete values from the enum and shift the rest of the values, because Unity actually saves the enum values as an `integer`. But using the enums is very comfortable, so I personally like this approach. If you want, you can look into associating explicit integer values to your enum values, and then it's safer to modify it. 101 | 2. **Add the `SoundManager` script as a component to a `GameObject`.** This hardly needs an explanation, if you have ever seen a computer before. 102 | 3. **Add entries to the `SoundManager` component's `Sound List` array in Inspector.** Change the 'None' sound type to an actual sound type, select your `AudioClip`, and set your pitch and volume ranges. Note that - obviously - Unity stores everything you add here with the given instance of the component, so you'd probably want to make a prefab from it, and use that in your scenes, or set it not to destroy on load. 103 | 104 | *(I have a `ScriptableObject`-based architecture that decouples configuration data from components, and possibly I'll convert my `SoundManager` to use that, but I felt it would complicate matters too much if I included it here. I intended to share this just as a simple but powerful component.)* 105 | 106 | 4. **You're ready to call `SoundManager`'s `PlaySound()` and `PlaySoundPositioned()` methods.** 107 | 108 | *(Note that `SoundManager`, as it is provided here, uses a very simple singleton implementation that exposes a static instance of itself, called `Instance`. This instance is created when the class is first instantiated, and any further instantiation (which shouldn't happen to begin with) will destroy itself. You can just change this to however you prefer to access your service components. For example you can just inject the SoundManager as a dependency into your classes through their constructor. Just kidding, since you can't use constructors at all in your Unity MonoBehaviour classes. ;P )* 109 | 110 | ## What this component doesn't have 111 | 112 | - There is no Editor customization provided. The display of arrays in the Unity Inspector is hideous, so I did include some trickery *(with platform-specific compile directives, restricting it to the Unity Editor)* to at least replace the default `'Element'`names with the value of the enums. But if you use some generic/universal Editor script package to prettify arrays, possibly even this is superfluous. 113 | - One functionality that would be nice to have is a 'Play' button right besides the sound settings, so you could listen to how does the AudioClip sound with the given ranges. Maybe I'll look into implementing this. 114 | 115 | ## Notes 116 | 117 | **Let me know if you happen to find any bugs**, or spot any sort of weirdness with the code. I'm coming from normal .Net development, so I can't rule out the possibility that I'm unaware of some weirdness in how Unity handles lifecycle of objects, coroutines, or who knows what. 118 | 119 | ## Licence 120 | 121 | Since it seems some people are using this, and actually someone asked me earlier about the licence... I added a permissive MIT licence for your peace of mind. 122 | -------------------------------------------------------------------------------- /SingletonMonoBehaviour.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using UnityEngine; 3 | 4 | namespace LeakyAbstraction 5 | { 6 | /// 7 | /// This is just a very minimal singleton helper class, facilitating easy access to the SoundManager class. 8 | /// Replace or customize it to your liking. 9 | /// 10 | [DisallowMultipleComponent] 11 | public abstract class SingletonMonoBehaviour : MonoBehaviour where T : SingletonMonoBehaviour 12 | { 13 | public static T Instance => _instance ?? throw new InvalidOperationException($"Pre-instantiation missing. An instance of {typeof(T).Name} is required to exist in the scene."); 14 | protected static T _instance; 15 | 16 | protected virtual void Awake() 17 | { 18 | if (_instance == null) 19 | _instance = (T)this; 20 | else if (_instance != this) 21 | Destroy(this); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /SoundManager-Inspector-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/baratgabor/Unity3D-SoundManager/b8e13d7082492d3b71d39fb425e47254ab6386a6/SoundManager-Inspector-example.png -------------------------------------------------------------------------------- /SoundManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using UnityEngine; 3 | using UnityEditor; 4 | using System.Collections; 5 | using System.Collections.Generic; 6 | using Random = UnityEngine.Random; 7 | 8 | namespace LeakyAbstraction 9 | { 10 | public class SoundManager : SingletonMonoBehaviour, ICoroutineControl 11 | { 12 | /// 13 | /// This nested class holds all data related to the playback of a single AudioClip. Instances of this class are exposed to the Inspector through the SoundManager class. 14 | /// 15 | [Serializable] 16 | public class SoundEntity 17 | { 18 | #if UNITY_EDITOR 19 | /// 20 | /// These members are part of the compilation only in the Unity Editor; i.e. they won't compile into your game. 21 | /// The string replaces the default 'Element' string displayed as the name of the entities inside the array. 22 | /// But, at the same time the field itself is hidden from editing. 23 | /// 24 | [HideInInspector] 25 | public string Name; 26 | public void SetName() 27 | => Name = soundType.ToString(); 28 | #endif 29 | 30 | public GameSound soundType; 31 | public AudioClip audioClip; 32 | 33 | [Range(0, 1)] 34 | public float volumeLow = 1f; 35 | [Range(0, 1)] 36 | public float volumeHigh = 1f; 37 | [Range(0, 2)] 38 | public float pitchLow = 1f; 39 | [Range(0, 2)] 40 | public float pitchHigh = 1f; 41 | } 42 | public enum LoggingType 43 | { 44 | None, 45 | LogOnlyInEditor, 46 | LogAlways 47 | } 48 | 49 | [Header("Number of simultaneous sounds supported at startup:")] 50 | [SerializeField] 51 | [Tooltip("Defines the number of AudioSources to create during initialization.")] 52 | private int _initialPoolSize = 10; 53 | 54 | [Header("Increase the number of simultaneous sounds on-demand:")] 55 | [SerializeField] 56 | [Tooltip("If set to TRUE, a new AudioSource will be created if all others are busy. If set to FALSE, the sound simply won't play.")] 57 | private bool _canGrowPool = true; 58 | 59 | [Header("Logging behavior:")] 60 | [SerializeField] 61 | [Tooltip("Note that selecting 'None' disables logging completely, so the next setting will have no effect.")] 62 | private LoggingType _loggingType = LoggingType.LogOnlyInEditor; 63 | 64 | [Header("Report sound types which don't have entries defines:")] 65 | [SerializeField] 66 | [Tooltip("If set to TRUE, all sound types defined in the Enum will be checked to see if there is at least one sound associated to them, and the unassigned ones will be reported in a warning.")] 67 | private bool _checkUnassignedSounds = true; 68 | 69 | [SerializeField] 70 | private SoundEntity[] _soundList = default; 71 | 72 | private Dictionary> _soundMap = new Dictionary>(); 73 | private Stack _soundPlayerPool; 74 | 75 | private bool _initialized = false; 76 | private readonly Vector3 _zeroVector = Vector3.zero; 77 | 78 | private const float RELEASE_MARGIN = 0.05f; 79 | private const float RETRYRELEASE_WAIT = 0.1f; 80 | private const string SOUNDPLAYER_GO_NAMEBASE = "SoundPlayer"; 81 | private int _soundPlayerNameIndex = 0; 82 | 83 | private SoundManagerDebugLogger _log; 84 | 85 | #if UNITY_EDITOR 86 | private void OnValidate() 87 | { 88 | // We're only interested in changes made in the Inspector 89 | if (!GUI.changed) 90 | return; 91 | 92 | // Set/update the names of entities to replace the generic 'element' name in Unity Inspector's array view 93 | foreach (var s in _soundList) 94 | s.SetName(); 95 | 96 | // If changes were made in the Inspector while the game is running, and we're past initialization, 97 | // process again the list of sounds to make sure our runtime representation is up to date. 98 | if (EditorApplication.isPlaying && _initialized) 99 | PopulateSoundMap(); 100 | } 101 | #endif 102 | 103 | /// 104 | /// Initialization. 105 | /// 106 | protected override void Awake() 107 | { 108 | base.Awake(); 109 | 110 | if (_initialized == true) 111 | return; 112 | 113 | SetupLogging(); 114 | PopulateSoundMap(); 115 | GrowPool(_initialPoolSize); 116 | _initialized = true; 117 | } 118 | 119 | private void SetupLogging() 120 | { 121 | switch (_loggingType) 122 | { 123 | case LoggingType.None: 124 | break; 125 | case LoggingType.LogOnlyInEditor: 126 | if (Application.isEditor) 127 | _log = new SoundManagerDebugLogger(); 128 | break; 129 | case LoggingType.LogAlways: 130 | _log = new SoundManagerDebugLogger(); 131 | break; 132 | default: 133 | Debug.LogException( 134 | new InvalidOperationException($"Unknown logging type '{_loggingType}' encountered. Cannot set up logging behavior.")); 135 | break; 136 | } 137 | } 138 | 139 | /// 140 | /// Plays the specified sound. 141 | /// If multiple sounds are registed for the given type, selects one randomly. 142 | /// 143 | /// The pre-defined identifier of the sound to play. 144 | /// Delegate to be invoked when playback completed. 145 | /// Returns the AudioSource that is playing the requested sound. Don't mess with it if you want the soundmanager to work dependably. 146 | public AudioSource PlaySound(GameSound soundType, Action playFinishedCallback = null) 147 | => Play_Internal(soundType, 1, 1, PlayMode.Simple, null, _zeroVector, playFinishedCallback); 148 | 149 | /// 150 | /// Plays the specified sound at the specified world position. 151 | /// If multiple sounds are registed for the given type, selects one randomly. 152 | /// 153 | /// The pre-defined identifier of the sound to play. 154 | /// The world position of the sound playback. 155 | /// Delegate to be invoked when playback completed. 156 | /// Returns the AudioSource that is playing the requested sound. Don't mess with it if you want the soundmanager to work dependably. 157 | public AudioSource PlaySoundPositioned(GameSound soundType, Vector3 soundPosition, Action playFinishedCallback = null) 158 | => Play_Internal(soundType, 1, 1, PlayMode.Positioned, null, soundPosition, playFinishedCallback); 159 | 160 | /// 161 | /// Plays the specified sound while following the specified transform's position. 162 | /// If multiple sounds are registed for the given type, selects one randomly. 163 | /// 164 | /// The pre-defined identifier of the sound to play. 165 | /// The transform to be followed during the playback. 166 | /// Delegate to be invoked when playback completed. 167 | /// Returns the AudioSource that is playing the requested sound. Don't mess with it if you want the soundmanager to work dependably. 168 | public AudioSource PlaySoundPositioned(GameSound soundType, Transform targetTransform, Action playFinishedCallback = null) 169 | => Play_Internal(soundType, 1, 1, PlayMode.Tracking, targetTransform, _zeroVector, playFinishedCallback); 170 | 171 | /// 172 | /// Plays the specified sound with volume and pitch overrides. 173 | /// If multiple sounds are registed for the given type, selects one randomly. 174 | /// 175 | /// The pre-defined identifier of the sound to play. 176 | /// The multiplier to apply to the volume. Applies on top of the random range value defined in Inspector. 177 | /// The multiplier to apply to the pitch. Applies on top of the random range value defined in Inspector. 178 | /// Delegate to be invoked when playback completed. 179 | /// Returns the AudioSource that is playing the requested sound. Don't mess with it if you want the soundmanager to work dependably. 180 | public AudioSource PlaySound(GameSound soundType, float volumeMultiplier, float pitchMultiplier, Action playFinishedCallback = null) 181 | => Play_Internal(soundType, volumeMultiplier, pitchMultiplier, PlayMode.Simple, null, _zeroVector, playFinishedCallback); 182 | 183 | /// 184 | /// Plays the specified sound with volume and pitch overrides, at the specified world position. 185 | /// If multiple sounds are registed for the given type, selects one randomly. 186 | /// 187 | /// The pre-defined identifier of the sound to play. 188 | /// The world position of the sound playback. 189 | /// The multiplier to apply to the volume. Applies on top of the random range value defined in Inspector. 190 | /// The multiplier to apply to the pitch. Applies on top of the random range value defined in Inspector. 191 | /// Delegate to be invoked when playback completed. 192 | /// Returns the AudioSource that is playing the requested sound. Don't mess with it if you want the soundmanager to work dependably. 193 | public AudioSource PlaySoundPositioned(GameSound soundType, float volumeMultiplier, float pitchMultiplier, Vector3 soundPosition, Action playFinishedCallback = null) 194 | => Play_Internal(soundType, volumeMultiplier, pitchMultiplier, PlayMode.Positioned, null, soundPosition, playFinishedCallback); 195 | 196 | /// 197 | /// Plays the specified sound with volume and pitch overrides, while following the specified transform's position. 198 | /// If multiple sounds are registed for the given type, selects one randomly. 199 | /// 200 | /// The pre-defined identifier of the sound to play. 201 | /// The transform to be followed during the playback. 202 | /// The multiplier to apply to the volume. Applies on top of the random range value defined in Inspector. 203 | /// The multiplier to apply to the pitch. Applies on top of the random range value defined in Inspector. 204 | /// Delegate to be invoked when playback completed. 205 | /// Returns the AudioSource that is playing the requested sound. Don't mess with it if you want the soundmanager to work dependably. 206 | public AudioSource PlaySoundPositioned(GameSound soundType, float volumeMultiplier, float pitchMultiplier, Transform targetTransform, Action playFinishedCallback = null) 207 | => Play_Internal(soundType, volumeMultiplier, pitchMultiplier, PlayMode.Tracking, targetTransform, _zeroVector, playFinishedCallback); 208 | 209 | private AudioSource Play_Internal(GameSound soundType, float volumeMultiplier, float pitchMultiplier, PlayMode playMode, Transform targetTransform, Vector3 targetPosition, Action playFinishedCallback = null) 210 | { 211 | var (canPlay, soundList) = SoundPlayPreChecks(soundType); 212 | 213 | if (!canPlay) 214 | return null; 215 | 216 | return _soundPlayerPool.Pop() 217 | .Play(GetRandomSound(soundList), volumeMultiplier, pitchMultiplier, playMode, targetTransform, targetPosition, playFinishedCallback); 218 | } 219 | 220 | /// 221 | /// Converts the Editor-compatible array into a fast-lookup dictionary map. 222 | /// Creates a list for each sound type, to support multiple sounds of the same type. 223 | /// 224 | private void PopulateSoundMap() 225 | { 226 | // No sounds entries defined at all 227 | if (_soundList == null || _soundList.Length == 0) 228 | { 229 | _log?.SoundEntries_NoneDefined(); 230 | return; 231 | } 232 | 233 | foreach (var s in _soundList) 234 | { 235 | // Silently skip entries where 'None' is selected as soundtype 236 | if (s.soundType == GameSound.None) 237 | continue; 238 | 239 | // Skip entries where audioclip is missing 240 | if (s.audioClip == null) 241 | { 242 | _log?.SoundEntries_FaultyEntry_NoAudioClip(s.soundType); 243 | continue; 244 | } 245 | 246 | if (_soundMap.TryGetValue(s.soundType, out var list)) 247 | // If a list already exists for the given sound type, simply add an additional entry to it. 248 | list.Add(s); 249 | else 250 | // If the list doesn't exist yet, instantiate and add a new list, and initialize it to contain the first entry. 251 | _soundMap.Add(s.soundType, new List() { s }); 252 | } 253 | 254 | // If requested, check and report which soundtypes don't have any sound entry associated in Inspector (i.e. can't play). 255 | if (_checkUnassignedSounds && _log != null) 256 | LogUnassignedSoundTypes(); 257 | } 258 | 259 | /// 260 | /// Checks all GameSound enum values to see if there is at least one sound assigned to it. 261 | /// If it finds enum values without any sound assigned, logs a warning to the console with the list of missing sound types. 262 | /// 263 | private void LogUnassignedSoundTypes() 264 | { 265 | List missingSoundsList = null; 266 | foreach (GameSound soundType in Enum.GetValues(typeof(GameSound))) 267 | { 268 | if (soundType == GameSound.None || _soundMap.ContainsKey(soundType)) 269 | continue; 270 | 271 | // Instantiation in this scope to avoid allocation if no reporting is needed. 272 | if (missingSoundsList == null) 273 | missingSoundsList = new List(); 274 | 275 | missingSoundsList.Add(soundType); 276 | } 277 | 278 | if (missingSoundsList != null) 279 | _log?.SoundEntries_SomeNotDefined(missingSoundsList); 280 | } 281 | 282 | /// 283 | /// Grows pool by the specified number. Creates pool if it doesn't yet exist. 284 | /// 285 | private void GrowPool(int num) 286 | { 287 | if (_soundPlayerPool == null) 288 | CreatePool(num); 289 | 290 | for (int i = 0; i < num; i++) 291 | _soundPlayerPool.Push(CreateSoundPlayer()); 292 | 293 | void CreatePool(int capacity) 294 | { 295 | // If initial pool size is greater, use that instead 296 | if (_initialPoolSize > capacity) 297 | capacity = _initialPoolSize; 298 | // If pool can grow, reserve double 299 | if (_canGrowPool) 300 | capacity *= 2; 301 | 302 | _soundPlayerPool = new Stack(capacity); 303 | } 304 | 305 | SoundPlayer CreateSoundPlayer() 306 | { 307 | _soundPlayerNameIndex++; 308 | var go = new GameObject(SOUNDPLAYER_GO_NAMEBASE + _soundPlayerNameIndex); 309 | go.transform.parent = this.transform; 310 | 311 | var audioSource = go.AddComponent(); 312 | 313 | var soundPlayer = new SoundPlayer(audioSource, this, _log); 314 | soundPlayer.PlaybackComplete += OnPlaybackFinished; 315 | 316 | return soundPlayer; 317 | } 318 | } 319 | 320 | /// 321 | /// Returns SoundPlayer to the pool after it finished playing a sound. 322 | /// 323 | private void OnPlaybackFinished(SoundPlayer player) 324 | => _soundPlayerPool.Push(player); 325 | 326 | /// 327 | /// Returns whether playback of a given soundtype is possible, and if so, returns all sound variations available for the given soundtype (or null). 328 | /// 329 | private (bool canPlay, List availableSounds) SoundPlayPreChecks(GameSound soundType) 330 | { 331 | // Initialization is expected to happen earlier 332 | if (!_initialized) 333 | { 334 | _log?.Initialization_HadToExpedite(); 335 | Awake(); 336 | } 337 | 338 | // Sound type 'None' is not valid for playback 339 | if (soundType == GameSound.None) 340 | { 341 | _log?.PlaybackFail_NoneSoundRequested(); 342 | return (canPlay: false, null); 343 | } 344 | 345 | var soundListExists = _soundMap.TryGetValue(soundType, 346 | out var soundList); // Note out var 347 | 348 | // No valid sound entries are defined for the requested soundtype - i.e. nothing to play 349 | if (!soundListExists) 350 | { 351 | _log?.PlaybackFail_NoEntryDefined(soundType); 352 | return (canPlay: false, null); 353 | } 354 | 355 | if (_soundPlayerPool.Count == 0) 356 | { 357 | // Playback fails if pool is exhausted, and we can't grow 358 | if (!_canGrowPool) 359 | { 360 | _log?.PlaybackFail_PoolExhausted(); 361 | return (canPlay: false, null); 362 | } 363 | 364 | // If pool can grow, grow pool, and proceed with playback 365 | _log?.PoolHadToGrow(); 366 | GrowPool(1); 367 | } 368 | 369 | return (canPlay: true, soundList); 370 | } 371 | 372 | /// 373 | /// Returns a random sound from a list of sounds. 374 | /// 375 | private SoundEntity GetRandomSound(List list) 376 | => list[Random.Range(0, list.Count)]; 377 | 378 | /// 379 | /// Encapsulates message logging. Bit messy, but helps to totally avoid string operations and allocations if logging is disabled. 380 | /// 381 | private class SoundManagerDebugLogger 382 | { 383 | public void SoundEntries_NoneDefined() 384 | => Debug.LogWarning($"No sound entries are defined for the {nameof(SoundManager)}. Won't be able to play any sounds."); 385 | 386 | public void SoundEntries_SomeNotDefined(List typesWithoutEntry) 387 | => Debug.LogWarning($"{nameof(SoundManager)} initialization didn't find any valid sound entries for the following sound types (these sounds cannot play): " + 388 | string.Join(", ", typesWithoutEntry.ToArray())); 389 | 390 | public void SoundEntries_FaultyEntry_NoAudioClip(GameSound missingClipType) 391 | => Debug.LogWarning($"An entry for soundtype '{missingClipType}' missing its {nameof(AudioClip)}. This entry will be ignored."); 392 | 393 | public void Initialization_HadToExpedite() 394 | => Debug.LogWarning("Sound playback was requested before SoundManager was initialized. Initializing now."); 395 | 396 | public void PlaybackFail_NoneSoundRequested() 397 | => Debug.LogWarning("Sound playback failed. Soundtype 'None' was requested to play. Specify a valid soundtype."); 398 | 399 | public void PlaybackFail_NoEntryDefined(GameSound soundType) 400 | => Debug.LogWarning($"Sound playback failed. Soundtype '{soundType}' has no sounds assigned to it."); 401 | 402 | public void PlaybackFail_PoolExhausted() 403 | => Debug.LogWarning($"Sound playback failed, because no {nameof(AudioSource)} was available. " + 404 | $"Increase the initial pool of {nameof(AudioSource)}s, or enable the on-demand creation of {nameof(AudioSource)}s."); 405 | 406 | public void PoolHadToGrow() 407 | => Debug.LogWarning($"All {nameof(AudioSource)}s were busy. New {nameof(AudioSource)} had to be instantiated for playback. " + 408 | $"If you see this often, it's advisable to increase the initial pool of {nameof(AudioSource)}s."); 409 | 410 | public void AudioSourceNeededExtraWait(int extraWaitNum) 411 | => Debug.LogWarning($"{nameof(AudioSource)} wasn't ready for release at the expected time. Waiting cycle: {extraWaitNum}. " + 412 | $"\nIf you see this often, consider increasing the {nameof(RELEASE_MARGIN)} constant."); 413 | } 414 | 415 | /// 416 | /// Encapsulates sound playback and AudioSource handling responsibilities. 417 | /// Provides notification of playback completion. 418 | /// 419 | private class SoundPlayer 420 | { 421 | public event Action PlaybackComplete; 422 | public bool IsPlaying => _isWaiting; 423 | 424 | private readonly SoundManagerDebugLogger _log; 425 | private readonly AudioSource _audioSource; 426 | private readonly Transform _audioTransform; 427 | private readonly ICoroutineControl _coroutines; 428 | 429 | private GameSound _currentSound; 430 | private Action _currentCallback; 431 | private Coroutine _currentCoroutine; 432 | private bool _isWaiting; 433 | 434 | public SoundPlayer(AudioSource audioSource, ICoroutineControl coroutineControl, SoundManagerDebugLogger log) 435 | { 436 | _log = log; 437 | _audioSource = audioSource; 438 | _coroutines = coroutineControl; 439 | 440 | // Cache 441 | _audioTransform = _audioSource.transform; 442 | } 443 | 444 | // Not used currently 445 | public void StopImmediate(Vector3 restorePosition) 446 | { 447 | if (!_isWaiting) 448 | throw new InvalidOperationException("Cannot stop, no active playback is registered."); 449 | 450 | _audioSource.Stop(); 451 | _audioTransform.position = restorePosition; 452 | _coroutines.StopCoroutine(_currentCoroutine); 453 | _isWaiting = false; 454 | DoStopped(); 455 | } 456 | 457 | public AudioSource Play(SoundEntity soundEntity, float volumeMultiplier, float pitchMultiplier, 458 | PlayMode playMode, Transform targetTransform, Vector3 targetPosition, Action callback = null) 459 | { 460 | _currentSound = soundEntity.soundType; 461 | _currentCallback = callback; 462 | var waitingTime = Play_Internal(soundEntity, volumeMultiplier, pitchMultiplier); 463 | 464 | switch (playMode) 465 | { 466 | case PlayMode.Simple: 467 | // Vanilla waiter 468 | _currentCoroutine = _coroutines.StartCoroutine( 469 | PlaybackWaiter(waitingTime)); 470 | break; 471 | case PlayMode.Positioned: 472 | // Positioned waiter 473 | _currentCoroutine = _coroutines.StartCoroutine( 474 | PlaybackWaiter_Positioned(waitingTime, targetPosition)); 475 | break; 476 | case PlayMode.Tracking: 477 | // Tracking waiter 478 | _currentCoroutine = _coroutines.StartCoroutine( 479 | PlaybackWaiter_Tracking(waitingTime, targetTransform)); 480 | break; 481 | default: 482 | throw new InvalidOperationException($"Cannot process unknown {nameof(PlayMode)} '{playMode}'."); 483 | } 484 | 485 | return _audioSource; 486 | } 487 | 488 | /// 489 | /// Preps the AudioSource and plays the specified sound. 490 | /// 491 | private float Play_Internal(SoundEntity sound, float volumeMultiplier, float pitchMultiplier) 492 | { 493 | // Prepare audio source 494 | var pitch = Random.Range(sound.pitchLow, sound.pitchHigh) * pitchMultiplier; 495 | _audioSource.volume = Random.Range(sound.volumeLow, sound.volumeHigh) * volumeMultiplier; 496 | _audioSource.pitch = pitch; 497 | _audioSource.clip = sound.audioClip; 498 | 499 | // Calculate actual time length of sound playback 500 | var playTime = Mathf.Abs(sound.audioClip.length / pitch); // Abs() is to support negative pitch 501 | 502 | // Start actual playback 503 | _audioSource.Play(); 504 | 505 | return playTime; 506 | } 507 | 508 | /// 509 | /// Waits for audio playback to finish, then executes notifications 510 | /// 511 | private IEnumerator PlaybackWaiter(float releaseAfterSeconds) 512 | { 513 | _isWaiting = true; 514 | 515 | // Actual wait 516 | yield return new WaitForSecondsRealtime(releaseAfterSeconds + RELEASE_MARGIN); 517 | 518 | // Make sure it's actually finished 519 | int extraWaits = 0; 520 | while (_audioSource.isPlaying) 521 | { 522 | // Report extra wait 523 | _log?.AudioSourceNeededExtraWait(++extraWaits); 524 | yield return new WaitForSeconds(RETRYRELEASE_WAIT); 525 | } 526 | 527 | _isWaiting = false; 528 | DoStopped(); 529 | } 530 | 531 | /// 532 | /// Extends normal waiter with a feature: positions the AudioSource's transform to follow another transform during playback 533 | /// 534 | private IEnumerator PlaybackWaiter_Tracking(float releaseAfterSeconds, Transform target) 535 | { 536 | var origin = _audioTransform.position; 537 | 538 | // Execute wait, as separately running coroutine 539 | _coroutines.StartCoroutine(PlaybackWaiter(releaseAfterSeconds)); 540 | 541 | while (_isWaiting) 542 | { 543 | _audioTransform.position = target.position; 544 | yield return null; 545 | } 546 | 547 | _audioTransform.position = origin; 548 | } 549 | 550 | /// 551 | /// Extends normal waiter with a feature: positions the AudioSource's transform during playback 552 | /// 553 | private IEnumerator PlaybackWaiter_Positioned(float releaseAfterSeconds, Vector3 soundPosition) 554 | { 555 | var origin = _audioTransform.position; 556 | _audioTransform.position = soundPosition; 557 | 558 | // Execute wait, waiting for its completion 559 | yield return PlaybackWaiter(releaseAfterSeconds); 560 | 561 | _audioTransform.transform.localPosition = origin; 562 | } 563 | 564 | /// 565 | /// Executes responsibilities due at playback completion 566 | /// 567 | private void DoStopped() 568 | { 569 | if (_isWaiting) 570 | throw new InvalidOperationException("Playback completion handling cannot execute. Active playback still registered."); 571 | 572 | PlaybackComplete?.Invoke(this); 573 | _currentCallback?.Invoke(_currentSound); 574 | 575 | _currentCallback = null; 576 | _currentCoroutine = null; 577 | _currentSound = GameSound.None; 578 | } 579 | } 580 | 581 | private enum PlayMode 582 | { 583 | Simple, 584 | Positioned, 585 | Tracking 586 | } 587 | } 588 | 589 | public interface ICoroutineControl 590 | { 591 | Coroutine StartCoroutine(IEnumerator routine); 592 | void StopCoroutine(Coroutine routine); 593 | } 594 | } 595 | --------------------------------------------------------------------------------