Above Average Audio!

Making the audio system in Homestead

;

Posted 03/15/2016 22:32:34 in Homestead

Updated 07/20/2017 19:29:53

Howdy! This week I thought I'd do something different once again-- I was asked to elaborate on the audio system that I mentioned last time and my progress on Homestead was relatively slow due to some external deadlines, so I'll write about the audio system! Since I like to keep my tutorial-ish posts as beginner-friendly as possible, I want to mention that some of the features of C# that I use might be unfamiliar to novice programmers and I won't go into detail about every line of code, but I'll do my best to at least mention the name of the concepts so that they are searchable :) I'll start by giving a high-level overview of how the system connects with Unity, and then I'll dive into some code:

One of the features I like most about FMOD is the ability to easily and dynamically control not only the volume and other effects of an audio loop, but also the section of music itself. That's kind of vague, so here's an example: Say you've written three versions of a music theme for an epic boss fight, and they are of different levels of intensity for the different stages of the battle. Instead of just fading between the themes as the player progresses through the fight, you can use FMOD to transition between them on-beat! This makes the music feel less like individual tracks on a media player, and more like one consistent piece of music, which is super awesome. Since FMOD Studio doesn't run natively on Linux (yet, hopefully!), I created a system that accomplishes this particular feature for Homestead.

To do this, I start with a manager-style class that handles most of the grunt work. I give it references to the appropriate Audio Mixer Groups for output. Using Mixer Groups is kind of optional, but I think it's worth taking the extra time to set them up so that you can easily separate your internal mixing from the master level mixing that you'll probably want to give to your players in the options menu. I also hook the class up to Homestead's event system, so that it can update itself each in-game hour instead of checking every frame in the update loop. And thirdly, I expose a small data structure so that it's easy to plug in the particular music clips that I want to use. This will all make sense in a little while, I promise! Lets get into some code:

cs


???using UnityEngine;
using UnityEngine.Audio;
using Homestead;

public class Audiographer : MonoBehaviour {
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

Well that's easy enough! I'm using UnityEngine.Audio to get at the Audio Mixer Group functionality, and the Homestead namespace is where I've defined some of the enums that we'll come across soon.

cs


    [System.Serializable]
    // Structure that represents data needed for a 0-1 fade.
    public class FaderData {
        public float fader        = 0;
        public float target        = 0;
        public float time        = 3;
        
        public bool IsOnTarget() {
            target = Mathf.Clamp(target, 0f, 1f);
            return fader == target;
        }
        public void Process() {
            fader += (1f / time) * Time.deltaTime * Mathf.Sign(target - fader);
            fader = Mathf.Clamp(fader, 0f, 1f);
        }
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

This is a little utility structure I created to help cut down on some repetitive code. Since I don't need the functionality outside of the audio system, I decided to keep it within the Audiographer class (although, it might be general enough for other purposes, like fading a sprite's transparency, if you want to copy it!). If you run the Process() method every frame, it will automatically move towards the target value over "time" seconds. I am currently using it to fade between ambient sounds and to fade the overall volume of the audio in and out on scene changes.

cs


    [System.Serializable]
    // Structure that represents a season's set of music clips.
    public class SeasonalSet {
        public AudioClip dayAmbience;
        public AudioClip nightAmbience;
        public AudioClip intro;
        public AudioClip morningTheme;
        public AudioClip afternoonTheme;
        public AudioClip nightTheme;
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

Here's another structure that exists to cut down on repetitive code and keep my hair from falling out. Instead of having loads of independent variables for every season, place, and time, I've grouped the universally related ones together. It should be fairly obvious from the variable names, but each season has three unique music loops, plus one intro (at the moment). Each season also has a day and night ambient loop, which you can hear change from birds to frogs in the last post's video!

cs


    // ----------------------------- //
    // Audiographer Configuration
    // ----------------------------- // ------- Defaults
    [Header("Music Sets")]
    [SerializeField]
    private SeasonalSet springSet;
    [SerializeField]
    private SeasonalSet summerSet;
    [SerializeField]
    private SeasonalSet autumnSet;
    [SerializeField]
    private SeasonalSet winterSet;
    
    [Header("Audio Groups")]
    [SerializeField]
    private AudioMixerGroup amgAmbient;
    [SerializeField]
    private AudioMixerGroup amgMusic;

    // ----------------------------- //
    // Components
    // ----------------------------- // ------- Defaults
    private AudioSource[] ambientSources;
    private AudioSource[] musicSources;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

Now we're getting into the actual class! Like I mentioned before, the SeasonalSet structure simplifies things a bit here. These are the variables that I expose in Unity's inspector for in-engine configuration. I generally choose to serialize private variables instead of using public variables for this; even though it's a bit ugly, it keeps the interface of the class cleaner when referencing it in other code (and keeps me from changing things I shouldn't be changing in code!). Also, if you haven't come across the Header attribute, it's a nice feature in Unity that puts a little header above the variables in Unity's inspector :) Finally, there are a couple arrays to keep track of the audio source components that will be created shortly.

cs


    // ----------------------------- //
    // Private Variables
    // ----------------------------- // ------- Defaults
    private int flip                    = 0;
    private FaderData ambFader            = new FaderData();
    private FaderData ambVolumeFader    = new FaderData();
    private FaderData musVolumeFader    = new FaderData();
    private SeasonalSet musicSet        = null;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

And here are a few extra variables that help things along. "flip" is an integer that helps with the arbitrary choice of which audio source to use next; it's the same technique that Unity devs used in their nice beatmatching example documentation. I declare the three faders that I will be using, and I set up a reference to the currently selected SeasonalSet.

cs


    void Start() {
        // Register for events
        Director.Get().RegisterHourChangeObserver(OnHourChange);
        
        // Set up correct music set
        switch (Director.Get().GetSeason()) {
            case SeasonType.Spring:
                musicSet = springSet;
                break;
            case SeasonType.Summer:
                musicSet = summerSet;
                break;
            case SeasonType.Autumn:
                musicSet = autumnSet;
                break;
            case SeasonType.Winter:
                musicSet = winterSet;
                break;
        }
        Debug.Assert(musicSet != null, "Audiographer: Invalid music set!");
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

Here's our first Unity callback! The first line registers the delegate OnHourChange() (which you'll see later) with Homestead's event system, so that the Audiographer can be notified when the hour changes instead of constantly checking (and hitting the game's performance). If you notice yourself doing lots of checks for various things in Unity's Update() function, look into the Observer Pattern and C# delegates-- in many cases, you can both boost your performance and clean up your code! After that's set up, I do a simple switch to pick out the right set of music to use and error check it.

cs


        // Set up sources
        ambientSources = new AudioSource[2];
        for (int i = 0; i < ambientsources.length;="" ++i)="" {="" ambientsources[i]=""><audiosource>();
            ambientSources[i].outputAudioMixerGroup = amgAmbient;
        }
        musicSources = new AudioSource[2];
        for (int i = 0; i < musicsources.length;="" ++i)="" {="" musicsources[i]=""><audiosource>();
            musicSources[i].outputAudioMixerGroup = amgMusic;
        }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

In this next section, I set up the audio sources. I chose to do this in code rather than in the inspector so that I can be confident that the sources are always set up the same way-- it's easy to forget that you changed a setting in the inspector when you're coming back to them after a few months trying to figure out why a sound isn't playing! I also over-engineered it a bit so that it'll be easy to add more than two sources if I need them later on.

cs


        // Play ambient loop
        float currentTime = Director.Get().GetTime();
        ambientSources[0].clip = currentTime < 17 ?
            musicSet.dayAmbience : musicSet.nightAmbience;
        ambientSources[0].loop = true;
        ambientSources[0].Play();
        
        // Play music intro and queue loop section
        AudioClip firstClip;
        if (currentTime < 12) firstClip = musicSet.morningTheme;
        else if (currentTime < 20) firstClip = musicSet.afternoonTheme;
        else firstClip = musicSet.nightTheme;
        
        musicSources[0].clip = musicSet.intro;
        musicSources[0].loop = false;
        musicSources[0].PlayScheduled(AudioSettings.dspTime + 1.0f);
        
        musicSources[1].clip = firstClip;
        musicSources[1].loop = true;
        musicSources[1].PlayScheduled(AudioSettings.dspTime + 1.0f +
            musicSet.intro.length);
    } // End of Start()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

Here's where I set up the sources with their initial clips. It's pretty straightforward, unless you've never seen the terrifying-looking conditional operator "?" before. All it does is check the boolean expression before the "?", then returns the expression before the ":" if it's true or the expression after the ":" if it's false. So to pick an ambient loop, I choose the dayAmbience clip if it's before 17:00 and the nightAmbience clip otherwise. The music is set up a little differently; I treat the two Audio Sources like the two decks of a DJ setup and queue the intro on the first and the correct looping segment on the second. I also use PlayScheduled(...) to delay each by a second in order to give the ambient sound some time before the intro begins. Another advantage of PlayScheduled(...) is that it's more accurate than the regular Play() function, which is essential when trying to play things on-beat without any pops or overlaps!

cs


    void Update () {
        // Process faders
        if (!ambFader.IsOnTarget()) {
            ambFader.Process();
            ambientSources[0].volume = 1f - ambFader.fader;
            ambientSources[1].volume = ambFader.fader;
        }
        if (!ambVolumeFader.IsOnTarget()) {
            ambVolumeFader.Process();
            amgAmbient.audioMixer.SetFloat("Attentuation",
                ambVolumeFader.fader * 20f - 20f);
        }
        if (!musVolumeFader.IsOnTarget()) {
            musVolumeFader.Process();
            amgMusic.audioMixer.SetFloat("Attentuation", musVolumeFader.fader
                * 20f - 20f);
        }
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

And here's our Update() function! I try to minimize the work I do in these for performance reasons, and here I'm just updating the faders that I mentioned earlier. "ambFader" fades between the two ambient "decks", much like a crossfader works on a DJ setup. The other two faders work more like individual volume faders for the overall volume of the ambient and music mixer groups. The weird-looking math adjusts the range from (0, 1) to (-20, 0), since a one-decibel change doesn't exactly work :P

cs


    public void OnHourChange(Director dr, TimeChangeEventArgs args) {
        if (args.hour24 == 12) {
            // Play midday music
            musicSources[flip].clip = musicSet.afternoonTheme;
            musicSources[flip].loop = true;
            musicSources[flip].PlayScheduled(AudioSettings.dspTime +
                musicSources[1-flip].clip.length - musicSources[1-flip].time);
            flip = 1 - flip;
            musicSources[flip].loop = false;
        }
        else if (args.hour24 == 17) {
            // Play night ambience (fade out/in)
            ambFader.target = 1f;
            ambientSources[1].clip = musicSet.nightAmbience;
            ambientSources[1].volume = 0;
            ambientSources[1].loop = true;
            ambientSources[1].Play();
        }
        else if (args.hour24 == 20) {
            // Play night music
            musicSources[flip].clip = musicSet.nightTheme;
            musicSources[flip].loop = true;
            musicSources[flip].PlayScheduled(AudioSettings.dspTime +
                musicSources[1-flip].clip.length - musicSources[1-flip].time);
            flip = 1 - flip;
            musicSources[flip].loop = false;
        }
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

Here's that event function I mentioned at the beginning! Since the way I set up the event system guarantees that this will be called every hour exactly once, I can simply check if it's the hour that I want the music to change and queue the music, as they say. In each case, I set the correct clip to the unused "deck", schedule it to play exactly when the current music reaches a looping point, and stop the currently playing music from looping. In the future, I'd like to adjust this to be able to handle looping at defined points within the clips instead of only at the end, but this works for now!

cs


    public void StartFade (bool fadingIn, float time) {
        ambVolumeFader.target = fadingIn ? 1 : 0;
        musVolumeFader.target = fadingIn ? 1 : 0;
        ambVolumeFader.time = time;
        musVolumeFader.time = time;
    }
    
    void OnDestroy() {
        Director.Get().UnregisterHourChangeHandler(OnHourChange);
    }
} // End of Audiographer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

And here are the final lines of the class. The StartFade(...) method really shows how simple the FaderData structure makes things; all I need to do is set the "target" variable and everything else is automatically handled. Since this class isn't persistent across scenes at the moment, I unregister the OnHourChange() event and make off a clean, hangingreference-free sunset!

I hope this writeup has been useful to you! Even if you don't use the technique verbatim, perhaps you picked up a C# concept or Unity quirk you didn't know about before. I'd love to hear thoughts on this-- whether it's helped, you're doing something similar in your own project, I said "declare" when I should've said "define", I should stop wasting my time and get back to making 3D models of cows, or anything else! Feel free to write me a comment below or on Twitter, I'm pretty good about getting back to people within a couple days :)

Comments

Today