Are there more efficient ways to keep rhythm? (RunService)

Hey Developers!
I’m currently working on a new project that I’m very passionate about, and I am excited to see where I can take it and hopefully showcase it sometime in the future.

However, I’m having some trouble with a core function of this project. Because it is rhythm-based, I need to perform many calculations within a short period of time for the system to function properly.

Because of this requirement, It is hard to determine what the best solution is for keeping the rhythm and making sure nothing feels offbeat, even when the framerate drops below 60FPS.

Here’s a portion of my code, which relies on RunService.RenderStepped to determine when a sound should be played.

rns.RenderStepped:Connect(function()
	local currentTime = script.Tutorial.TimePosition
	for i,v in pairs(keyTimes) do
		for ii,vv in pairs(v) do
			if script.Tutorial.TimePosition >= vv and script.Tutorial.TimePosition <= vv+.1 then
				table.remove(v,ii)
				print("Get Ready...")
				script:FindFirstChild(i):Play()
				break
			end
		end
	end
end)

I have attempted both RunService.Heartbeat and task.wait(), but the current method seems to be the most effective.

However, when performance drops, RenderStepped will call less frequently, resulting in a slightly offbeat feel that makes it nearly impossible to match the rhythm when the framerate drops to or below 30FPS. (which is obviously common on lower-end devices)

Here’s a video clip of the game running with FPS unlocked: (behaves almost identical to 60FPS)

And here’s a clip of the game running with FPS locked at 30:

If you need any more information to help please let me know.
Thanks!

some things you can use to speed it up:

  • use the local variable, currentTime
    • referencing script.Tutorial.TimePosition constantly inside of your loop will slow it down
  • prioritize playing the sound using :Play before things like table.remove
    • removing a key value from a table uses a lot of energy
    • you could store an array of keys to remove to iterate through after playing the sounds that need to be played
  • minimize how many iterations you need to loop through by grouping
    • the example below would lower iterating through 6 items each frame to 3 items each frame
      • you’d only iterate through the table for the current second/minute/halfSecond/etc
    • the 10 and 11 key could be extracted in this case using (keyframe time) modulo 1
    • e.g: if you have keyframes at 10.05, 10.1, 10.15, 11.02, 11.5, and 11.8 seconds, store them in a table like:
{
   [10] = {10.05, 10.1, 10.15},
   [11] = {11.02, 11.5, 11.8},
}
  • minimize how many iterations you need to loop through by using a different search algorithm
    • keeping the time positions sorted, then using something like binary search should be fine for finding the largest keyframe time less than the currentTime
    • idk why you have a nested table, but note that your break only breaks out of the inner loop
      • this causes you to loop through all of the nested tables at least once
3 Likes

Thank you so much for reviving this thread… :pray:

I did this because I needed to categorize certain keyframes within subtables to determine which sound it is that will be played, and using break reduces the amount of times the loop iterates because each sound will only be played once per beat. (while others may be played at the same time) Here’s an example:

local keyFrames = {
	["Sound1"] = {
		beatTime*1, -- beatTime being the amount of seconds between each beat
		beatTime*5, -- in the song.
		beatTime*15
	},
	["Sound2"] = {
		beatTime*2,
		beatTime*6,
		beatTime*17
	}
}

I understand the idea of grouping keyframes by a seconds/minutes and using the % operator to iterate within the correct group, but how would I structure this with multiple sounds? The script needs to know exactly which sound to play at exactly the right time. (sometimes at the same time of other sounds)

What other methods are there for searching? Are there better ways to iterate or structure the table of keyframes? :thinking:

mb earlier, I was misremembering modulo thinking it gives the quotients instead of remainders in division, so instead of just modulo, I’d use this to generate the index math.floor(time) % N

suppose N is 3 in the function to generate the index from above, it’d look like this

{
   ["Sound1"] = {
      [0] = {
         3.5, 6.05, 12.2,
      },
      [1] = {
         1.22, 7.2, 7.55, 16,
      [2] = {
         2.2, 5.125, 11.2, 20.01,
   },
   ["Sound2"] = {
      ...
   },
}

in this case, when the time is 5.0 in the audio, you’d have math.floor(5) % 3, or 2, then you’d scan through the buckets keyframes.Sound1[2] and keyframes.Sound2[2]
then similarly to your script.Tutorial.TimePosition <= vv+.1, because 5.0 has the risk of skipping an keyframe from 4.999, you should also scan through keyframes.Sound1[1] and keyframes.Sound2[1]

N=3 isnt too good of an example here because in the example above, you’re only skipping keyframes.Sound1[0] and .Sound2[0], but with N=6 or more, you’d skip a lot more iterations

Also, note that that table is generated. You’d store the table as you were before to keep it easy to hardcode, then before the song starts, you pretty much organize the data to be processed by your code easier

I’d do a binary search for this if you kept the old structure, but it might slow down your code if you do it with the bucketing from above

2 Likes

There’s a few things you could improve. First, you’re searching for “the sound to play at this very frame” every single frame. You could instead store “the next sound to be played” and check if that one should be played on this frame, and if so move on to the next keyFrame. That way you don’t have to do a linear search every frame. Second, your choice of data structures doesn’t lend itself super well to easily searching through it quickly. Choose a data structure that fits your needs. If you don’t want to let go of easy editing, do some preprocessing at the start of the game to turn your easily edited structure into an easily searched structure. It doesn’t matter if this takes a while because it only happens once at startup and not while the song is played. Third, you’re searching inefficiently even for the data structure you’ve picked.

Here’s some example code that improves on the two first points

local sounds = {} --Do some preprocessing so you can get the sounds in constant time instead of searching each time with FindFirstChild
for _, child in ipairs(script:GetChildren()) do
    if not child:IsA("Sound") then continue end
    sounds[child.Name] = child
end

local beatsBySound = {
    [sounds.Sound1] = { 1, 5, 15, },
    [sounds.Sound2] = { 2, 6, 17, },
}
--Do some preprocessing so we have a nicer data structure for searching/iterating through
local keyFramesByTime = {}
for sound, beats in pairs(beatsBySound ) do
    for _, beat in ipairs(beats) do
        local time = beat * beatTime
        local keyFrame = keyFramesByTime[time]
        if not keyFrame then
            keyFrame = {time = time * , sounds = {}}
            keyFramesByTime[time] = keyFrame
        end
        table.insert(keyFrame.sounds, sound)
    end
end
local keyFrames = {} --Even more preprocessing :3 Turn it into an array so we can sort it
for time, keyFrame in pairs(keyFramesByTime) do
    table.insert(keyFrames, keyFrame)
end
table.sort(keyFrames, function(a, b)
    return a.time < b.time
end)

--Or you could avoid all this preprocessing by layout out your data like this:
local keyFrames = {
    {time = beatTime * 1, sounds = {sounds.Sound1}},
    {time = beatTime * 5, sounds = {sounds.Sound1}}
}
local prevTime = 0
local anyOutOfOrder = false
for index, keyFrame in ipairs(keyFrames) do
    if keyFrame.time < prevTime then
        warn(("KeyFrame no. %d is out of order!!!"):format(index))
        anyOutOfOrder = true
    end
    prevTime = keyFrame.time
end
if anyOutOfOrder then
    error("Some KeyFrames are out of order!")
end

function playKeyFrames()
    return coroutine.wrap(function()
        for _, keyFrame in ipairs(keyFrames) do --No complicated logic for figuring out the next keyFrame, just use a for loop
            coroutine.yield(keyFrame)
            for _, sound in ipairs(keyFrame.sounds) do
                sound:Play()
            end
        end
    end)
end

function playSong()
    for keyFrame in playKeyFrames() do
        while keyFrame.time > script.Tutorial.TimePosition do --We're still checking every frame, but we're only checking *one* keyFrame. 
            RunS.RenderStepped:Wait()
        end
    end
end

I can’t really test this so let me know if it has bugs.

3 Likes

Thank you @ThanksRoBama & @4SHN for your great replies!

I will be using the tips and methods you guys provided me with to improve my current script. Thank you both again for helping me learn more efficient methods to bring this project to life. :smile:

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.