Task.wait() is very inconsistent

I want to create a loop that produces a sort of “stacking effect”, by repeating the same sound in a loop before the end of its duration.

Heres an example of this exact sample and how it would sound like in sonic-pi:

This is how its done in Roblox:

local SoundService = game:GetService("SoundService")

local Samples = SoundService.Samples

local expectedInterval = 0.5
local lastTime = tick()

while true do
	local currentTime = tick()
	local elapsedTime = currentTime - lastTime
	lastTime = currentTime

	local waitTime = expectedInterval - elapsedTime
	if waitTime < 0 then waitTime = 0 end
	lastElapsed = task.wait(waitTime)
	
	local c = Samples.loop_mika:Clone()
	c.Parent = SoundService
	c:Destroy()
end

You might’ve noticed, that I have task.wait() compensation, which would prevent inaccuracies in task.wait() from affecting the sound. Good solution, right?

No. No, it’s not even close to a solution.

Running the bellow code in-game makes the culprit apparent, as the audio occasionally tends to very slightly clip into the next beat, which as you’ve seen here above, snowballs very quickly into an uncontrollable mess.

game.SoundService.Samples.loop_mika:Play() task.wait(1) game.SoundService.Samples.loop_mika:Stop()

And so, I am asking, if there is any more precise method of pausing the loop, that isn’t running task.wait(), as that would be the solution to this problem.

1 Like

Heads up, running task.wait(0) is the same as running task.wait().

You can actually set the TimePosition of a Sound Instance before running Play. See if that helps.

The task.wait(0) case was for any negative values (and to be honest, that piece of code in itself is kind of rushed, though any kind of compensation seems to only provide a very, very short baind-aid fix).

There is no reason for me to set the TimePosition of the sample, as I am destroying a PlayOnRemove = true sound for intentional clipping, intending to play it at TimePosition 0, with the entire sound itself being unchanged, only having more of itself layered on over time.

task.wait will not wait the exact amount you put. Instead, it decides to stop waiting after it sees it has taken at least that much time. To do this, the function sets what time the task should be continued, and it will check this every resumption cycle. A cycle happens every frame.

Sounds are also unreliable when it comes to exactly when they play and loop, so that could also be causing an issue.

1 Like

I was aware of the first problem, though not really of the second. What could be the potential solution to these synchronization issues?

I just realized your first code doesn’t have a lastElapsed variable. Did you mean to use lastTime?

Try using TimePosition on the sound to find the actual midpoint and then play the next sound when it reaches it

I’m not sure if there’s a solution to this. The solution I would have thought of is something you’re already using.

If you’re talking about my sonic-pi code, then yes. It doesn’t have to compensate for time, because sonic-pi’s sleep implementation is very accurate in time.

If you’re talking about the command-line code at the bottom, that simply plays the sample for a second, then stops, which can be run multiple times, which reveals the occasional clipping/general time inaccuracies.

The sound is cloned, then destroyed, to allow for it to play without having copies of the same sample spawn over and over, preventing any reading of its variables, though, I could try to see how it would work under that implementation.

1 Like

I’d change the way it’s implemented and not delete the sound, while getting the current TimePosition of the sound to accurately tell where in the audio length it’s being emitted yk

I modified your piece of code and seems to sync up fine.

--!strict

local SoundService = game:GetService("SoundService")

local sound: Sound = script.Sound
local expectedInterval: number = 0.5
local lastTime: number = os.clock()

while true do
	local currentTime: number = os.clock()
	local elapsedTime: number = currentTime - lastTime

	local waitTime: number = expectedInterval - elapsedTime
	
	if waitTime > 0 then
		local actualTime: number = task.wait(waitTime)
		lastTime += actualTime
	end

	local sample = sound:Clone()
	sample.Parent = SoundService
	sample:Destroy()
end

Apparently, TimePosition doesn’t actually fire any events upon change. :sad:

1 Like

I’ve tried your code, and the same issue takes place (I would’ve recorded the difference, but it sounds basically identical to what I recorded in the top post).

Maybe it’s an issue with the sound itself, though I’d doubt it. It seems fine; by itself it loops perfectly with Looped = true.

I also probably should’ve linked the audio itself, so here it is:

1 Like

you don’t need to get it every time it changes, all you need is to check the value once it’s playing to get the position the sound is at and use that to figure out when the next sound is supposed to start playing yk?

Always returns a fat zero.
image

local SoundService = game:GetService("SoundService")

local Samples = SoundService.Samples

local expectedInterval = 0.5
local lastTime = tick()

local function createSound()
	local c = Samples.loop_mika:Clone()
	c.Parent = Samples
	c:Play()
	local d = c.Changed:Connect(function()
		print(c.TimePosition)
		if c.TimePosition >= 1 then d:Disconnect() createSound() end
	end)
end

createSound()

don’t do it in a .Changed connection, just do it in a loop or wait until the sound is loaded and playing and then get the TimePosition

I may be a bit off here but, from working with many languages there is a unwritten rule more or less not to call 0 time waits … wait(), wait(0) or the task.wait version. Roblox uses this in some templates so I assume your 0 time calls are not truly 0 time. If I need to go low, I will always use 0.33 or even 0.033. these are old assembler tricks on faking one FPS wait. The fact you are finding a 0 wait not consistent isn’t shocking. Many languages would just lock up with a call like that.

Edit: It would be interesting to see your test using this technique. I got a feeling it will pass with flying colors with a task.wait …

Like this? The wording was a little confusing.
Either way, the issue persists.

local SoundService = game:GetService("SoundService")
local Samples = SoundService.Samples

local offset

while true do
	local c = Samples.loop_mika:Clone()
	c.Parent = Samples
	c:Play() -- doesnt seem to make a difference if its before or after

	if not c.IsLoaded then c.Loaded:Wait() end
	
	local nextOffset = c.TimePosition
	print(nextOffset) -- still returns 0
	if offset then c.TimePosition += offset end

	task.wait(math.max(0.5 - nextOffset, 0))
	offset = nextOffset
end