Syncing events with music!

Syncing your events with music!


Introduction

First of all, I must give a big thanks to this post by Graham Tattersall. He gives an in-depth explanation of music syncing in Unity and C#.

Wonder how developers sync their events to the exact beat of a song? You may see this in rhythm games, intense moments, and just games that rely on music for their gameplay. This can engage your audience, but it is quite confusing at first when implementing this to your game.

What is the plan?

Now someone would just ask the following:
“Couldn’t you just use timers? If I just figure out the time in seconds I want the event to fire, and it would be in sync. Right?”

First of all, audio can have delays and can freeze depending on the player’s performance. This can lead to events being heavily out of sync to the actual beat.

In fact, I tried this out myself, and it would go out of sync every time.


The correct approach would be to track the song position and the song position in beats to get our event in sync.

That way, even if you have audio delays and unexpected frame drops, the song position will never be interrupted.

The important thing is how do we track them? All we need is a simple script called the conductor

Conductor Script

If you are handling an event on the server for everyone to see (Example: Fireworks), then your conductor script should be a ServerScript.

If you are handling an event on the client for only a specific player to see (Example: Making a text appear), then your conductor script should be a LocalScript.

Before we start coding, we need to set up attributes.

  • songPosition (NumberValue)
  • songPositionInBeats (NumberValue)

image

Now in our script, we need to declare some variables.

local runS = game:GetService("RunService")

local audio = script.Parent.SomeRandomAudio

-- Important Data
local bpm = 150
local secPerBeat = 0
local songPosition = 0
local songPositionInBeats = 0
local firstBeatOffset = 0
local dspSongTime = 0

We are using RunService to run every frame.

bpm is a variable that stands for Beats Per Minute. It is very complex to calculate the BPM of a song. You will need to search up the bpm of a song. There are many websites that help you find the bpm of a song. I recommend tunebat.

secPerBeat is a variable that tells how many seconds are in a beat.

songPosition is a variable that determines the song position of a song. A song position of 10 for example means that 10 seconds have passed when the song started.

songPositionInBeats is the same thing for songPosition, but it is calculated in beats.

firstBeatOffset is a variable that is basically the offset. Songs have a little pause at the beginning. This variable fixes that.

dspSongTime is a variable that tells how much time has passed until the song actually starts.


We will have two functions

  • Start()
  • Update()

Update() runs every frame.
Start() runs at the very beginning only once.

Adding on to our script, we have the following:

local runS = game:GetService("RunService")

local audio = script.Parent.SomeRandomAudio

-- Important Data
local bpm = 150 -- Change this value to the BPM of your song.
local secPerBeat = 0
local songPosition = 0
local songPositionInBeats = 0
local firstBeatOffset = 0
local dspSongTime = 0

function Start()

end

function Update()

end

Start()
runS.Heartbeat:Connect(Update)

runS.Heartbeat:Connect(Update) makes the Update() function run every single frame. You can learn more about it here.

In the Start() function, we type the following:

function Start()
	secPerBeat = 60.0 / bpm
	dspSongTime = audio.TimePosition
	
	audio:Play()
end

secPerBeat is calculated by dividing bpm by 60. This will help us determine our songPositionInBeats.

We also play the audio in this function.

In the Update() function, we update songPosition and songPositionInBeats every frame.

function Update()
	songPosition = audio.TimePosition - dspSongTime - firstBeatOffset
	songPositionInBeats = songPosition / secPerBeat

	script:SetAttribute("songPosition", songPosition)
	script:SetAttribute("songPositionInBeats", songPositionInBeats)
end

We update the attributes once we calculated the songPosition and songPositionInBeats.

We get our songPosition by subtracting audio.TimePosition by dspSongTime to take care of our delay. We also subtract that by firstBeatOffset to compensate for the small pause in the audio.

Dividing songPosition by secPerBeat will give us the songPositionInBeats.

That is it for our Conductor script!

Firing our event.

We will make a new script. Just like the Conductor script, if you are handling the event on the server, then our new script should be a ServerScript and vice-versa.

You can name this script anything you want. In this case, I will name this “EventManager”. This script will be fairly short.

We will need a few variables.

local runS = game:GetService("RunService")

-- We are getting this variable to get our attributes. Specifically our songPositionInBeats.
local conductor = script.Parent.Conductor

-- This is in beats. Our event will fire on the tenth beat.
local eventFireTime = 10

Then, we need to run our Update() function every frame.

function Update()

end

runS.Heartbeat:Connect(Update)

In our Update() function, we check if our songPositionInBeats is greater than our eventFireTime. If this is true, then we fire our event.

function Update()
    if eventFireTime <= conductor:GetAttribute("songPositionInBeats") then
		print("This print statement has been fired on the tenth beat! The best part is that it is in sync!")
	end
end

Now…
Wait… that is it? What a short script. We are done!

Conclusion

There you have it! You are now able to sync your events to a specific beat in your music! I hope this helps, especially if you are creating a rhythm game from scratch. If you have a question, feel free to ask.


Once again, big thanks to this post below as it gives an in-depth explanation about what I am going over in this tutorial.

Bye!

42 Likes

Can you show us a video of it actually working? I’m on my phone and unable to go try the script. :+1:

5 Likes

It broke. I don’t think you can change it.

ServerScriptService.Event Handler:12: attempt to compare number <= nil
1 Like

Maybe try storing the attribute in a variable or saying script.Parent:WaitForChild("Conductor"). FindFirstChild will also work.