Looping Natural Sounds

Posted On: 2019-08-19

By Mark

While working on my Game Jam game Single Sighted I found myself improvising solutions to a number of problems that I haven't faced before. A few of those solutions seem good enough to use in future projects as well, so for today's post, I'll go over one of these: randomly selecting ambient sounds from a pool. I'll describe both how the algorithm works conceptually as well as provide the code that was actually used during the jam. (The jam code includes some bugs, so consider it merely illustrative of the algorithm.)

Note: If you haven't played Single Sighted please do so before reading further. This post will pull back the curtain on how certain parts of the game work, so reading it may impact your experience (if you're sensitive to that kind of thing.)

Goals

Most of the gameplay of Single-Sighted revolves around the use of audio, specifically nature sounds. I had relatively few (<10) sounds for each creature, but needed each creature to produce sound reliably and frequently enough that a player could locate them using sound alone. As such, I needed a way to reuse the sounds without them feeling repeated or otherwise unnatural. The approach also needed to be flexible enough to handle an unknown number of sounds - that way I would be free to add or cut sounds without needing to rewrite any code.

Approach

At a high-level, the approach to solve this was to maintain two collections - a list of eligible sounds and a queue of sounds to play - ensuring that a sound was always in exactly one of these two collections. As sounds in the Queue are played, they are added to the list of eligible sounds. Whenever the list of eligible sounds is sufficiently large, sounds are removed from the list in a random order, and those sounds are added to the end of the Queue (in the order they were removed.) Additionally, after each sound is played, it waits a random (small) amount of time before playing the next sound.

Each of these individual pieces are designed to address a different kind of pattern that could potentially emerge. Using a Queue maximizes the number of other sounds that play before a specific sound is repeated*. This is especially important when working with a small list of short sounds, since the player may recognize a sound if it was played too recently. The list of eligible sounds allows for randomizing the order that items are added back into the Queue. This is helpful for avoiding the formation of patterns in the sequence of sounds that play (if, for example, sound A was always followed by sound B, then that would seem unnatural, especially after repeated exposure to the same sequence.) Finally, by adding a randomized delay between sounds, it avoids the appearance of a rhythmic pattern if several sounds have the same duration. Using identical duration for the rests between sounds generally made vocal sounds (such as the cat meows or bird songs) feel less natural.

Implementation

Although I am happy with the general design of the algorithm, the code itself can definitely use some cleanup. It was designed during a game jam, so I took shortcuts where I could (including plenty of hard coded values that are "good enough" rather than well thought-out.) If you're interested in seeing the code anyway, the (collapsed) code below is the implementation for the birds*:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class BirdSource : MonoBehaviour
{
   public AudioSource[] BirdPlayers;
   public AudioClip[] BirdOptions;

    //The sounds are referenced by index rather than by the actual sound
    //Using indeces makes the code simpler and more stable (duplicate entries would crash the game)
    //Comments that refer to moving sounds are doing so out of convenience - the code is actually moving indeces around.
    private Queue<int> RemainingPlays = new Queue<int>();
    private List<int> EligiblePlays = new List<int>();

    void Start()
    {
        for (var i = 0; i < BirdOptions.Length; i++)
        {
            //Loads all the possible bird sounds into the list of eligible sounds to play
            EligiblePlays.Add(i);
        }
        //Moves eligible sounds into the Queue, in a random order.
        UpdatePlayOptions();

        for(var i =0; i< BirdPlayers.Length; i++)
        {
            var player = BirdPlayers[i];
            StartCoroutine(LoopPlay(player));
        }
    }

    public IEnumerator LoopPlay(AudioSource source)
    {
        //Sounds are played as long as this script continues running coroutines.
        while (true)
        {
            //Takes the sound from the front of the queue.
            var index = RemainingPlays.Dequeue();
            source.clip = BirdOptions[index];

            //Adds a random amount of delay to the end of each clip
            // The delay is increased by the source clip length to avoid delays that sound unnaturally short/long compared to their duration.
            var delay = source.clip.length + Random.Range(0.1f, 1.1f);
            source.Play();

            //Adds the sound that was just played to the list of eligible sounds
            EligiblePlays.Add(index);

            //Waits until the clip has completed and also a random amount of additional time has passed
            yield return new WaitForSecondsRealtime(source.clip.length + delay);

            //If 3 or more sounds are eligible to be added to the queue, then we have enough to randomize them effectively
            //Note: 3 happens to work fine, but a larger number might be better since that introduces more randomness.
            //      It has to be larger than 1, but if it gets too large then it may run out of sounds in the Queue, which would break this script.
            if(EligiblePlays.Count >= 3)
            {
                //Moves eligible sounds into the Queue, in a random order
                UpdatePlayOptions();
            }
        }
    }

    //Moves eligible sounds into the Queue, in a random order.
    private void UpdatePlayOptions()
    {
        //Temporary list, to keep track of which sounds were added to the queue.
        var consumedValues = new List<int>();
        for (var i = 0; i < EligiblePlays.Count; i++)
        {
            var val = EligiblePlays[Random.Range(0, EligiblePlays.Count)];
            //Keep picking random numbers until "val" is a number has not been added to the queue.
            while (consumedValues.Contains(val))
            {
                val = EligiblePlays[Random.Range(0, EligiblePlays.Count)];
            }
            //Add that sound to the queue, and track that it has been added.
            RemainingPlays.Enqueue(val);
            consumedValues.Add(val);
        }
        //All items in the list are now in the queue, so the list should be emptied.
        EligiblePlays.Clear();
    }
}

One thing I'd love to revisit in this code is how the random queuing works. In the current implementation it brute-forces the random until all the required numbers are generated, which is only as elegant as one needs in a game jam. Since then, I've been reading up a bit on approaches to card shuffling (since this is basically the same problem) and it's pretty clear there are better approaches.

Conclusion

Hopefully this exploration of how I handled picking random sounds from a limited pool was interesting to read. It's not something I'd really considered before the game jam, so I'm glad that I've had this chance to try to tackle the issue (and I'm doubly happy that it seems to work so well.) As always, if you have any thoughts or feedback please let me know.