
Alternative Sound Manager for small and medium projects on Unity3D

This article will be useful for both novice developers to gain experience and get the best practices, as well as avid architects whose offices do not cease to argue about the importance of separating the view from the model and removing statics from the code. I am sure that the solution I have proposed is not completely universal and has its drawbacks, however, an important and pleasant element for me would be that every interested habrayuzer would draw something useful for himself and improve his own modules using my advice.
Problems
Angry loner
Many may disagree with me, but I believe that the use of singletones, especially in aspects such as sound reproduction, is unacceptable in projects of any scale. Using this
Examples:
static void PlayMusic(string name);
static void PlaySound(string name, bool pausable = true);
The method insists that third-party code is aware of specific melody names. It is the responsibility of the programmer in each of the modules responsible for their sounds to pass arguments correctly. And there can be a lot of such places in the project: various UI elements, shooting / dying units, environment. In the comments to the ref article, one of the readers suggests using different channels for sound in the arguments, which also logically connects sections of code:
public void PlayFX (AudioClip clip, SoundFXChannel channel = SoundFXChannel.First, bool forceInterrupt = false) { }
public void StopFX (SoundFXChannel channel) { }
Now, for example, buttons (or, if you like, UIManager) using methods must take into account which of the channels they belong to, in fact, this is again the responsibility of the programmer.
Too much access
It has always been strange for me that when I call a method in separate code, they return the type inheritance from MonoBehaviour. Is it safe to launch coroutines on it? Did the developer protect it from Destroy ()? Or do I want to see “using UnityEngine” in the code in the future, or do I not need MonoBehaviour? This problem partially applies to the previous paragraph on singleton, we don’t need a link to the instance itself, we have enough API to work with it. Funny, but even if you implement a static call this way:
private static SoundManager instance;
public static ISoundManager Instance { get{ return (instance as ISoundManager) }}
When you receive an abstraction, you still have to use a specific type:
ISoundManager sm = SoundManager.Instance;
Which solves the problem only partially.
Stitched path and direct download
private AudioClip LoadClip(string name)
{
string path = "Sounds/" + name;
AudioClip clip = Resources.Load(path);
return clip;
}
Delayed loading of sounds, in my opinion, does not always make sense. Firstly, in the settings for importing sounds into a unit, you can configure how to store sound: immediately in RAM, stream from disk or load into memory, but convert immediately before playback. Learn more about import settings . Secondly, the experience of analyzing unit assembly logs suggests that the average amount of sound resources on the average size is in 3rd or lower place. And memory optimization, if you start, it is not clear from the sounds. (Of course, this is potentially not applicable to projects whose gameplay is tied to sounds). More about logs .
Now, regarding the path sewn into the code: Again, the responsibility of the programmer is to monitor the correspondence of the path when transferring this module from project to project. These dances begin when a good thought comes to the team: “Why not make a git submodule, put an audio manager there, so that in all projects, if necessary, would there be the latest version of this module?”. Since the path is embedded in the code, we cannot change it, since on other projects it will become erroneous. On the other hand, if you change the path only locally, then the git will always shine on you this change.
Own decision
The module code is located at: https://github.com/hexgrimm/Audio
To publish as part of the article, the code was simplified, I removed most of the tests and abstractions for them, so that the code looks more understandable. In projects under my leadership, a module with a slightly greater extensibility potential and volume configuration is used.
So, to start, let's talk about architecture:
This audio module is considered the final sheet in the dependency graph of any architecture, it does not require any dependencies below the graph, and it does not matter who creates it, but there is a limitation: This module must have a “Singleton” lifestyle (not to be confused with the Singleton design pattern, more details in the book “Dependency injection in .NET” by Mark Siman). This is due to the Unity3D requirement for only one AudioListener per application. If you are using dependency injection in a project, then the binders will look like this (using Ninject as an example):
binder.Bind().To().InSingletonScope();
In case you just want to create this class and use it in your project, make sure that all sources of the audio playback call are provided with abstractions of the same instance.
As an example:
var ac = new AudioController();
IAudioController iac = ac;
IAudioPlayer iap = ac;
IMusicPlayer imp = ac;
And in the future, work and delivery to all sources is carried out only with abstractions iac, iap, imp.
Abstraction
IAudioController, an interface designed for general sound control (on / off, overall volume):
IAudioController
public interface IAudioController : IDisposable
{
///
/// Enabled or disables all sounds in game. All music sources sets volume to = 0 and stops their playback;
///
bool SoundEnabled { get; set; }
///
/// Enables or disables all musics in game. All music sources sets volume to = 0 or MusicVolume value;
///
bool MusicEnabled { get; set; }
///
/// Sound volume range 1 - 0
///
float SoundVolume { get; set; }
///
/// Music volume in range 1 - 0
///
float MusicVolume { get; set; }
}
IAudioPlayer, the interface is designed to play 2D and 3D sounds, and further control them.
IAudioPlayer
public interface IAudioPlayer
{
///
/// plays audio clip if sound enabled.
///
/// Audio clip to play.
/// volume in range 1 - 0, when plays its also affected by global volume setting.
/// should clip play be looped
/// returns code for this sound call to control playback for concrete clip played.
int PlayAudioClip2D(AudioClip clip, float volumeProportion = 1f, bool looped = false);
///
/// Plays audio clip in concrete 3d position
///
/// Audio clip to play
/// world position of audio source.
/// parameter seted to audioSource.MaxDistance
/// volume in range 1 - 0, when plays its also affected by global volume setting.
/// should clip play be looped
///
int PlayAudioClip3D(AudioClip clip, Vector3 position, float maxSoundDistance, float volumeProportion = 1f, bool looped = false);
///
/// stop playing concrete clip.
///
/// code, recived from methods PlayAudioClip2D or PlayAudioClip3D
void StopPlayingClip(int audioCode);
///
/// Returns true if audio code contains in player and can be controlled.
///
/// audio code
///
bool IsAudioClipCodePlaying(int audioCode);
///
/// Sets global audio listener to concrete position
///
/// v3 in world coordinates
void SetAudioListenerToPosition(Vector3 position);
///
/// Set position of source if source exist.
///
/// code of source
/// target position in world coordinates
void SetSourcePositionTo(int audioCode, Vector3 destinationPos);
}
IMusicPlayer, music playback and control.
IMusicPlayer
public interface IMusicPlayer
{
///
/// plays music clip as 2d sound with concrete volume padding.
///
/// music clip
/// volume proportions of sound in range of 1 - 0. Its also affected by global music volume settings
/// concrete music playback code for future control
int PlayMusicClip(AudioClip clip, float volumeProportion = 1f);
///
/// stops playing music clip and clear data for this code.
///
/// audio code to find audio clip playback
void StopPlayingMusicClip(int audioCode);
///
/// Pauses concrete music clip play, it could be resumed.
///
///
void PausePlayingClip(int audioCode);
///
/// Resumes concrete music clip play if it was paused before.
///
///
void ResumeClipIfInPause(int audioCode);
///
/// Returns true if audio code contains in player and can be controlled.
///
/// audio code
///
bool IsMusicClipCodePlaying(int audioCode);
}
When calling the method of reproducing sound or music, the consumer is given a numerical code by which he can further control the sound.
For example, turn it off or change the position of the sound source if the object is moving.
A separate method is:
SetAudioListenerToPosition(Vector3 position);
In the case of a 3d sound and a moving listener, it is necessary to provide access to control its position.
You may have noticed that one of the arguments to call playback is the AudioClip type, in my opinion, the logic for storing or associating clips and sound sources should not be in the controller itself, so I just took these powers out of the module, thereby allowing the consumer of the module to decide whether to create whether the sound storage base or to associate clips directly with sources (in most of our cases this happens. Different units have female and male voices, this information is an integral part of units, whatever kind of Inca sulyatsiya have not been applied; and that the unit delivers this information using the interface IAudioPlayer).
You may also notice that IAudioController inherits from IDisposable. This is intentional and justified by the limitations that Unity3D imposes. In the Dispose method, the unit objects created to ensure the module’s operability are deleted, in my opinion, the scene objects are “separately-managed” resources relative to the module, and since AudioController is not MonoBehaviour, we cannot call Destroy (). And the garbage collector will not be able to clear the links, since unit-driven links will be alive. By invoking the Dispose method, we guarantee that all resources and links associated with the unit have been cleared. Although in small projects the life cycle of an audio module is always similar in length to the cycle of an application, so maybe you should not bother.
I also apologize for the large number of lines of the form:
source.pitch = 1 + Random.Range(-0.1f, 0.1f);
The use of magic numbers is, of course, unacceptable, and they are intentionally written for an example, since the configuration that we pass through the constructor in real projects complicates the code, and I would like to leave the code as simple as possible for beginners.
I’ll say a few words about the class SavableValue <>. The utility class for storing any serializable types in Prefs had to be duplicated in this module, so as not to pull a separate namespace Utils. I don’t know how BinaryFormatter works well on non-mobile platforms.
What happened as a result
Without using Singleton in the project, we create a convenient seam, and in the future we can replace abstractions if necessary. Now you can write any test for the reproduction of a class of sound just using mock abstraction.
IAudioPlayer mock = Substitute.For();
var testClass = new Class(mock);
Access to classes is limited by interfaces, nothing more can be done with them (if you do not take into account the abuse with incorrect audioCode). No unnecessary dependencies except namespace HexGrimmDev.Audio does not stretch. As in the recommendations of Mark Simon, all unnecessary responsibility is transferred to the class and, if necessary, can be transferred through the constructor. There are no external logical connections; you can distribute the module as a git-submodule.
I understand that not all isolations are equally useful, but in this case a lot of extra time was not required to create a seam. For more inspiration, I propose to read Oleg Chumakov’s lecture on the topic “ Why should your Unity project work in the console?” "
And I also strongly recommend passing links by modules through the constructor, this is of course clearer for the consumer, and besides, it damn discipline. And most importantly, I propose not to pursue full universalization. There is an excellent lecture on this topic " How not to get carried away by the pursuit of the universalization of components ."
Functional list in code example:
- Play and control 2d and 3d sounds as well as music.
- Sound balancing. (a float argument with a 0-1 range is passed for the exact balancing of individual sounds) (taken into account when changing the volume)
- The possibility of looping.
- Changing the position of the listener for 3d sounds.
- There is a random pitch + -0.1f shift for all sounds except music. (for example)
- Pause and resume for music.
Of the specific features:
- AudioMixer is not used.
- There are a lot of magic numbers in the code, it is subject to refactoring before use.
- There is no smooth transition between music videos, you can implement in many ways.
- Due to code cutting and after deleting the tests, there is a chance that something does not work correctly, the code is primarily an example, and not a means.
- To write tests, it is recommended to enter a seam between the components of the unit and AudioController, and work with AudioSource and AudioListener through additional abstractions, and in the test replace abstractions with dummies. In addition, the test will be performed in a minimum of time.