swifttimernstimer

Solid Metronome System


I am trying to build a reliable solid system to build a metronome in my app using Swift.

I Have built what seems to be a solid system using NSTimer so far.. The only issue I am having right now is when the timer starts the first 2 clicks are off time but then it catches into a solid timeframe.

Now after all my research I have seen people mention you should use other Audio tools not relying on NSTimer.. Or if you choose use NSTimer then it should be on its own thread. Now I see many confused by this Including myself and I would love to get down to the bottom of this Metronome business and get this solved and share it with all those who are struggling.

So I have implemented and cleaned up at this point after the feedback I had last recieved. At this point here is how my code is structured. Its playing back. But I am still getting 2 fast clicks in the beginning and then it settles in.

I apologize on my ignorance for this one. I hope I am on the right path.

I currently am prototyping another method as well. Where I have a very small audio file with one click and dead space at the end of it with the correct duration until for a loop point for specific tempos. I am looping this back and works very well. But the only thing Is I dont get to detect the loop points for visual updates so I have my basic NStimer just detecting the timing intervals underneath the audio being processed and it seems to matchup very well throughout and no delay. But I still would rather get it all with this NSTimer. If you can easily spot my error would be great for one more kick in the right direction and I am sure it can work soon!

//VARIABLES 
//AUDIO
var clickPlayer:AVAudioPlayer = AVAudioPlayer()
let soundFileClick = NSBundle.mainBundle().pathForResource("metronomeClick", ofType: ".mp3")

//TIMERS
var metroTimer = NSTimer()
var nextTimer = NSTimer()

var previousClick = CFAbsoluteTimeGetCurrent()    //When Metro Starts Last Click

//Metro Features
var isOn            = false
var bpm             = 60.0     //Tempo Used for beeps, calculated into time value
var barNoteValue    = 4        //How Many Notes Per Bar (Set To Amount Of Hits Per Pattern)
var noteInBar       = 0        //What Note You Are On In Bar

    
//********* FUNCTIONS ***********
      
func startMetro() {
    MetronomeCount()
        
    barNoteValue    = 4         // How Many Notes Per Bar (Set To Amount Of Hits Per Pattern)
    noteInBar       = 0         // What Note You Are On In Bar
    isOn            = true      //
}
    
//Main Metro Pulse Timer
func MetronomeCount()
{
    previousClick = CFAbsoluteTimeGetCurrent()
    
    metroTimer = NSTimer.scheduledTimerWithTimeInterval(60.0 / bpm, target: self, selector: Selector ("MetroClick"), userInfo: nil, repeats: true)
    
    nextTimer = NSTimer(timeInterval: (60.0/Double(bpm)) * 0.01, target: self, selector: "tick:", userInfo: ["bpm":bpm], repeats: true)
}


func MetroClick()
{
    tick(nextTimer)
}

func tick(timer:NSTimer)
{
    let elapsedTime:CFAbsoluteTime = CFAbsoluteTimeGetCurrent() - previousClick
    let targetTime:Double = 60/timer.userInfo!.objectForKey("bpm")!.doubleValue!
    if (elapsedTime > targetTime) || (abs(elapsedTime - targetTime) < 0.003)
    {
        previousClick = CFAbsoluteTimeGetCurrent()
        
        //Play the click here
        if noteInBar == barNoteValue
        {
            clickPlayer.play()    //Play Sound
            noteInBar = 1
        }
        else//If We Are Still On Same Bar
        {
            clickPlayer.play()    //Play Sound
            noteInBar++             //Increase Note Value
        }
        
        countLabel.text = String(noteInBar)     //Update UI Display To Show Note We Are At
    }
}

Solution

  • A metronome built purely with NSTimer will not be very accurate, as Apple explains in their documentation.

    Because of the various input sources a typical run loop manages, the effective resolution of the time interval for a timer is limited to on the order of 50-100 milliseconds. If a timer’s firing time occurs during a long callout or while the run loop is in a mode that is not monitoring the timer, the timer does not fire until the next time the run loop checks the timer.

    I would suggest using an NSTimer that fires on the order of 50 times per desired tick (for example, if you would like a 60 ticks per minute, you would have the NSTimeInterval to be about 1/50 of a second.

    You should then store a CFAbsoluteTime which stores the "last tick" time, and compare it to the current time. If the absolute value of the difference between the current time and the "last tick" time is less than some tolerance (I would make this about 4 times the number of ticks per interval, for example, if you chose 1/50 of a second per NSTimer fire, you should apply a tolerance of around 4/50 of a second), you can play the "tick."

    You may need to calibrate the tolerances to get to your desired accuracy, but this general concept will make your metronome a lot more accurate.

    Here is some more information on another SO post. It also includes some code that uses the theory I discussed. I hope this helps!

    Update The way you are calculating your tolerances is incorrect. In your calculations, notice that the tolerance is inversely proportional to the square of the bpm. The problem with this is that the tolerance will eventually be less than the number of times the timer fires per second. Take a look at this graph to see what I mean. This will generate problems at high BPMs. The other potential source of error is your top bounding condition. You really don't need to check an upper limit on your tolerance, because theoretically, the timer should have already fired by then. Therefore, if the elapsed time is greater than the theoretical time, you can fire it regardless. (For example if the elapsed time is 0.1s and and the actual time with the true BPM should be 0.05s, you should go ahead and fire the timer anyways, no matter what your tolerance is).

    Here is my timer "tick" function, which seems to work fine. You need to tweak it to fit your needs (with the downbeats, etc.) but it works in concept.

    func tick(timer:NSTimer) {
        let elapsedTime:CFAbsoluteTime = CFAbsoluteTimeGetCurrent() - lastTick
        let targetTime:Double = 60/timer.userInfo!.objectForKey("bpm")!.doubleValue!
        if (elapsedTime > targetTime) || (abs(elapsedTime - targetTime) < 0.003) {
            lastTick = CFAbsoluteTimeGetCurrent()  
            # Play the click here
        }
    }
    

    My timer is initialized like so: nextTimer = NSTimer(timeInterval: (60.0/Double(bpm)) * 0.01, target: self, selector: "tick:", userInfo: ["bpm":bpm], repeats: true)