Now that the sampler’s making noise, let’s make it make noise in time! In order to get the music ticking away, we’ll need a clock. So let’s make a metronome!
Lesson 3 – Metronome
Timing in Unity
Musical timing depends on something ticking at a constant interval, but game frame rate can vary widely depending on what else is being processed that frame and what device the game is running on. So we need to make our timing frame rate independent, and as accurate as possible so the music sounds in time. Thankfully, Unity gives us an accurate clock in the form of AudioSettings.dspTime. Querying this value gives us the current time in seconds of the audio system as a double-precision floating point number. We can then use that to schedule sounds some time in the future based on a constant interval.
Parameters
To start, we’ll need a couple parameters for our Metronome class. To make it easy to think about in musical terms, we’ll add a tempo parameter, which is how many beats occur per minute (BPM). And to determine the minimum resolution of our clock, we’ll also specify the number of subdivisions per beat that the Metronome should tick.
[SerializeField, Tooltip("The tempo in beats per minute"), Range(15f, 200f)] private double _tempo = 120.0; [SerializeField, Tooltip("The number of ticks per beat"), Range(1, 8)] private int _subdivisions = 4;
Note that we utilize the Tooltip and Range attributes provided by Unity. This lets us add a tooltip for further explanation when you hover over that field in the Unity editor, and tell Unity to make a slider with the specified range to keep the values reasonable.
Lookahead
Because of the variable time a game frame can take, we’ll want to look ahead into the future to schedule our metronome ticks. This gives us increased accuracy and keeps the Metronome from drifting. To make this happen, we’ll keep track of the next time a Metronome tick should occur, and check to see if that tick would happen within the next frame.
Here’s what the update code looks like:
private void Update() { double currentTime = AudioSettings.dspTime; // look ahead the length of one frame (approximately), // because we'll be scheduling samples currentTime += Time.deltaTime; // there may be more than one tick within the next frame, // so this will catch them all while (currentTime > _nextTickTime) { // if someone has subscribed to ticks from the metronome, // let them know we got a tick if (Ticked != null) { Ticked(_nextTickTime); } // increment the next tick time _nextTickTime += _tickLength; } }
As you see in the above code, we get the current audio system time, look ahead the length of one frame, and then check to see if there’s a tick about to happen. We then report to anyone who’s listening that a tick will happen, and pass the time in the future it should happen.
A Note on Framerate
You may be wondering what happens if we get a framerate spike. Will that mess this up? The answer is maybe. If the current frame takes longer than the previous frame, and the difference is enough that one or more ticks should happen during it, then on the next frame, we’ll trigger all of those extra ticks right away. This will result in a noticeable shift if it’s one extra tick, and a shift plus a stacking of ticks if it’s more than one. In practice, though, this will be rare. Here’s why.
Let’s say our game’s target framerate is 60FPS. That means the length of a frame is:
1 / 60 = 0.01666... seconds = ~16 milliseconds
And let’s make our metronome tick every 16th note at 120BPM. The length of a beat will be:
1 / 120 = 0.008333... minutes = 0.5 seconds = 500 milliseconds
And therefore, the length of a tick will be:
500 / 4 = 125 milliseconds
This means that in order for a long frame to cause problems with the metronome, that frame needs to take at least 109ms, which in game frame time is a very, very long time. That’s not to say it doesn’t happen, but you’re unlikely to run into problems with it very often. If your metronome absolutely must be bulletproof, though, there are ways to do that, such as updating the metronome on the audio thread and tracking ticks at audio sample resolution. But for the sake of this tutorial, we will hand-wave on past that complexity.
Playing Sounds, in the Future
Now that we have our metronome ticking away, we’ll need to modify our SamplerVoice class to play sounds some time in the future. Thankfully, the AudioSource component gives us a way to schedule sounds–the PlayScheduled function.
In our SamplerVoice code, we’ll add a start time parameter to the Play function, and swap out the AudioSource.Play function for PlayScheduled:
public void Play(AudioClip audioClip, double startTime, double attackTime, double sustainTime, double releaseTime) { sustainTime = (sustainTime > attackTime) ? (sustainTime - attackTime) : 0.0; _envelope.Reset(attackTime, sustainTime, releaseTime, AudioSettings.outputSampleRate); double timeUntilTrigger = (startTime > AudioSettings.dspTime) ? (startTime - AudioSettings.dspTime) : 0.0; _samplesUntilEnvelopeTrigger = (uint)(timeUntilTrigger * AudioSettings.outputSampleRate); _audioSource.clip = audioClip; _audioSource.PlayScheduled(startTime); }
Also, because we’re scheduling the sound to happen in the future, we’ll need to delay our volume envelope. Because we’re applying the envelope per sample, we’ll track how many samples remain until the envelope should start, and calculate that value in the same way as we calculate our envelope durations in the previous lesson.
We still need one change to the SamplerVoice class, which is waiting to start getting the envelope values until the sound has started playing. To do that, we’ll just count down the samples in OnAudioFilterRead:
private void OnAudioFilterRead(float[] buffer, int numChannels) { for (int sIdx = 0; sIdx < buffer.Length; sIdx += numChannels) { double volume = 0; if (_samplesUntilEnvelopeTrigger == 0) { volume = _envelope.GetLevel(); } else { --_samplesUntilEnvelopeTrigger; } for (int cIdx = 0; cIdx < numChannels; ++cIdx) { buffer[sIdx + cIdx] *= (float)volume; } } }
Subscribing to Metronome Ticks
To bring it all together, our Sampler class will need to listen for ticks from the Metronome. You may have noticed the Ticked event in the Metronome class. This is the mechanism the Metronome uses to report to any other objects who have subscribed that a tick has occurred. Additionally, it passes the time that the tick occurred, and because of the lookahead, that will be our scheduled play time.
To listen for ticks from the Metronome, all we need to do is subscribe to it in the Sampler’s OnEnable function:
private void OnEnable() { if (_metronome != null) { _metronome.Ticked += HandleTicked; } }
The Sampler’s HandleTicked function is pretty much the same as the previous version’s Play function, except that it takes the time the tick occurred and passes it on to the SamplerVoice:
private void HandleTicked(double tickTime) { _samplerVoices[_nextVoiceIndex].Play(_audioClip, tickTime, _attackTime, _sustainTime, _releaseTime); _nextVoiceIndex = (_nextVoiceIndex + 1) % _samplerVoices.Length; }
Hooking it Up
Now that we have the code set up, let’s hear it in Unity. Create an empty GameObject and add the Metronome component. Set up your Sampler as before, and make sure to drag the Metronome object into the Metronome field. Hit play, and you should hear the sample playing at whatever tempo you specified on the Metronome component. Play with the tempo and subdivision sliders and it should update the speed of the sample triggers.
Thanks again for following along. If you have any questions or comments, please let us know! Next up: step sequencing