Rhythmic Exactness, BMP, and Music Timing

Started by Midimaster, August 27, 2024, 13:34:10

Previous topic - Next topic

Midimaster

I was asked to think about a perfect timing when firing audio sounds with BlitzMax. Now I wrote a runnable demo app, where you can see the difference between a Millisecs()-based timing approach and a delta-based timing approach.

The exactness of audio is a little bit tricky. Reason for the problems is the f.e. FLIP command, which returns to system for 16msec every flip. This means, that the next time measuring is only possible 16msec later. But this is too late for high speed audio timing. Also with FLIP 0 your graphic can slow down the performance and influence the audio rhythm

This happens already at TEMPO=120 with only quarter notes. But to better demonstrate the inaccuracy, my example now uses TEMPO=250 and 1/16-clicks. The faster the song is, the more you hear the problem.

We use a fine resolution of 24ticks/quarter note to be able to enable 1/32-notes also as 1/16-triblets.
Code: BASIC
TickTime = 60000.0 / 24 / Tempo


If you run at TEMPO=250, this means that the distance between music ticks (TickTime) is only 10msec. But the FLIP only returns every 16msec. So you have to calculate the next TimeStamp not from Millisecs(), but by adding it to old TimeStamp .
Code: BASIC
Function DoClick()
    If TimeStamp>MilliSecs() Return
    ...
    KlickCounter:+ 1
    TimeStamp:+ TickTime


Also important: The TimeStamp and the TickTime needs to be double precise to handle also 10.04msec (which means tempo 249)
Code: BASIC
Global TickTime:Double = 20
Global TimeStamp:Double = MilliSecs()


When we now calculate TimeStamp from old TimeStamp, it can happen, that we need to add TickTime twice to TimeStamp within one main loop :

See this table:
Code: BASIC
FlipTime NeedToAdd  TimeStamp
---------------------------------------
0        +10       = 10
16       +10       = 20
32       +10       = 30
         +10       = 40  !!!
48       +10       = 50
60       +10       = 60
         +10       = 70  !!!
76       +10       = 80


So we need a inner Repeat/Until-Loop to guarantee, that TimeStamp is bigger than Millisecs()
Code: BASIC
Function DoClick()
    If TimeStamp>MilliSecs() Return

    Repeat
        KlickCounter:+ 1
        TimeStamp:+ TickTime
        If KlickCounter Mod 6 = 0  '  mod 24 for quarter notes
            PlaySound Klick
        EndIf
    Until TimeStamp>MilliSecs()
End Function



Here is a runable demo app.

It demonstrates the difference between the two ways to calculate ticks timing.

You can switch between both ways with SPACE key.
You can change musical TEMPO with mouse-X
You can simulate some graphic DELAY with mouse-Y

Then you have to wait 10sec for an exact measuring of the real happened ticks.

You can see, that my timing keeps absolute stabile, even when graphics cost performance.

Code: BASIC
SuperStrict
Graphics 800,600
Global Klick:TSound = LoadSound("klack.ogg") 
Global Tempo:Int=120, KlickCounter:Int, Slowdown:Int, LastMillisecs:Int
Global OneMinute:Int, Counted:Int, PreviousCounter:Int, DoItOldWay:Int

Global TickTime:Double = 20
Global TimeStamp:Double = MilliSecs()

Repeat
	Cls
	If DoItOldWay=True
		DoClickOld
	Else
		DoClickNew
	EndIf 
	DoUser
	DoGraphics
	CountTheTicks
Until AppTerminate()


Function DoGraphics()
	Cls
	DrawText "Move the mouse in x direction to change TEMPO ", 200,100
	DrawText "Move the mouse in y direction to add a Graphic DELAY ", 200,120
	DrawText "Press SPACE to switch between old and new way ", 200,140

	If DoItOldWay=True
		DrawText "  A U D I O   T I M I N G   V I A   M I L L I S E C S () " , 100,70
	Else
		DrawText "  A U D I O   T I M I N G   V I A   D E L T A  - T I M I N G  ", 100,70
	EndIf 

	DrawText "Selected TEMPO: " + Tempo, 100,200
	DrawText "      TickTime: " + TickTime, 100,240
	DrawText "     TimeStamp: " + TimeStamp, 100,280
	DrawText "Ticks Expected in 10sec: " + Int(10000/TickTime), 100,300

	DrawText " added Delay: " + Slowdown + "msec", 200,400
	If counted>10
		DrawText "Ticks Counted in 10sec: " + Counted, 200,460
	EndIf 
	LastMillisecs=MilliSecs()
	Delay SlowDown
	Flip 0
End Function




Function DoClickOld()
' a Millisecs() based timing for the ticks:
	If TimeStamp>MilliSecs() Return


	TimeStamp = MilliSecs() + TickTime
	KlickCounter:+ 1
	If KlickCounter Mod 6 = 0  '  mod 24 for quarter notes
		PlaySound Klick
	EndIf 
End Function



Function DoClickNew()
' a Delta based timing for the ticks:
	If TimeStamp>MilliSecs() Return

	Repeat
		KlickCounter:+ 1
		TimeStamp:+ TickTime
		If KlickCounter Mod 6 = 0  '  mod 24 for quarter notes
			PlaySound Klick
		EndIf 
	Until TimeStamp>MilliSecs()
End Function


Function DoUser()
	If Tempo<>Int(MouseX()/4+50) Then OneMinute=0
	Tempo = MouseX()/4+50
	TickTime = 60000.0 / 24 / Tempo
	Slowdown = MouseY()/50
	
	If KeyHit(KEY_SPACE)
		DoItOldWay=1-DoItOldWay
	EndIf 
End Function 


Function CountTheTicks()
	If OneMinute > MilliSecs() Return
	
	Counted = (KlickCounter - PreviousCounter)'/24
	PreviousCounter = KlickCounter
	OneMinute = (MilliSecs()+10000)
End Function 


You need my sound KLACK.OGG or any metronome sound.

download from here:
midimaster.de/temp/Klack.ogg



...on the way to China.

Derron

You might consider separating "graphics" and "sound" handling. For now both run in the "main thread".

A computer process can always have "hickups" ... so times during the execution in which a "loop" takes longer than expected.

Flip with vsync just ensures to "wait" for a vsync ... I am not sure what happens if you eg have a delay of "20" on a 60Hz display. Does it render right once it reaches "flip" (so 4ms after the "vsync") or does it now even wait additional 12ms (so skipped one vsync and waiting for the next).


While you cannot guarantee no hickups happening, stuff like "audio output" should possibly run on a different thread. This way it becomes independent from a "hardware restriction" for a subject it does not care for (graphics instead of audio).
Similar you would need to do if the audio would have to take care for things being sent to the sound card/chip ... imagine your "flip" command would need to wait for it. Result would be delayed rendering/graphics "stuttering".

-> separate your audio stuff into a thread
-> create a semaphore ("producer/consumer" stuff)
-> use the semaphore to tell your audio thread it got something to "play"
-> enqueue "to play stuff" (PlayAudio) there 
-> audio thread actually plays the audio and "sleeps" afterwards again (except there is even more to "consume")

Dunno how big the "delay" will be there (how long it takes from "enqueuing" to "actually playing").

You can of course also combine it with some realtime audio creation in that thread or other audio related things "done there".


Give it a whirl and let's see if that can improve the situation (feel free to approach via discord so we can create stuff more "rapidly" there).


bye
Ron