Search code examples
iosiphoneswiftmetronome

Metronome ios swift beat visuals lag


I'm trying to create an metronome app by implementing the sample code provided by apple. Everything works fine but i'm seeing an delay in the beat visuals its not properly synchronised with the player time. Here is the sample code provided by apple

let secondsPerBeat = 60.0 / tempoBPM
let samplesPerBeat = Float(secondsPerBeat * Float(bufferSampleRate))
let beatSampleTime: AVAudioFramePosition = AVAudioFramePosition(nextBeatSampleTime)
let playerBeatTime: AVAudioTime = AVAudioTime(sampleTime: AVAudioFramePosition(beatSampleTime), atRate: bufferSampleRate)
// This time is relative to the player's start time.

player.scheduleBuffer(soundBuffer[bufferNumber]!, at: playerBeatTime, options: AVAudioPlayerNodeBufferOptions(rawValue: 0), completionHandler: {
self.syncQueue!.sync() {
self.beatsScheduled -= 1
self.bufferNumber ^= 1
self.scheduleBeats()
}
})

beatsScheduled += 1

if (!playerStarted) {
// We defer the starting of the player so that the first beat will play precisely
// at player time 0. Having scheduled the first beat, we need the player to be running
// in order for nodeTimeForPlayerTime to return a non-nil value.

player.play()
playerStarted = true
}
let callbackBeat = beatNumber
beatNumber += 1
// calculate the beattime for animating the UI based on the playerbeattime.
let nodeBeatTime: AVAudioTime = player.nodeTime(forPlayerTime: playerBeatTime)!
let output: AVAudioIONode = engine.outputNode
let latencyHostTicks: UInt64 = AVAudioTime.hostTime(forSeconds: output.presentationLatency)
//calcualte the final dispatch time which will update the UI in particualr intervals
let dispatchTime = DispatchTime(uptimeNanoseconds: nodeBeatTime.hostTime + latencyHostTicks)**
// Visuals.
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: dispatchTime) {
if (self.isPlaying) {
// send current call back beat.
self.delegate!.metronomeTicking!(self, bar: (callbackBeat / 4) + 1, beat: (callbackBeat % 4) + 1)

}
}
}
// my view controller class where i'm showing the beat number
class ViewController: UIViewController ,UIGestureRecognizerDelegate,Metronomedelegate{

@IBOutlet var rhythmlabel: UILabel!
//view did load method
override func viewDidLoad() {


}
//delegate method for getting the beat value from metronome engine and showing in the UI label.

func metronomeTicking(_ metronome: Metronome, bar: Int, beat: Int) {
    DispatchQueue.main.async {
        print("Playing Beat \(beat)")
//show beat in label
       self.rhythmlabel.text = "\(beat)"
    }
}
}

Solution

  • I think you are approaching this a bit too complex for no reason. All you really need is to set a DispatchTime when you start the metronome, and fire a function call whenever the DispatchTime is up, update the dispatch time based on the desired frequency, and loop as long as the metronome is enabled.

    I prepared a project for you which implements this method so you can play with and use as you see fit: https://github.com/ekscrypto/Swift-Tutorial-Metronome

    Good luck!

    Metronome.swift

    import Foundation
    import AVFoundation
    
    class Metronome {
        var bpm: Float = 60.0 { didSet {
            bpm = min(300.0,max(30.0,bpm))
            }}
        var enabled: Bool = false { didSet {
            if enabled {
                start()
            } else {
                stop()
            }
            }}
        var onTick: ((_ nextTick: DispatchTime) -> Void)?
        var nextTick: DispatchTime = DispatchTime.distantFuture
    
        let player: AVAudioPlayer = {
            do {
                let soundURL = Bundle.main.url(forResource: "metronome", withExtension: "wav")!
                let soundFile = try AVAudioFile(forReading: soundURL)
                let player = try AVAudioPlayer(contentsOf: soundURL)
                return player
            } catch {
                print("Oops, unable to initialize metronome audio buffer: \(error)")
                return AVAudioPlayer()
            }
        }()
    
        private func start() {
            print("Starting metronome, BPM: \(bpm)")
            player.prepareToPlay()
            nextTick = DispatchTime.now()
            tick()
        }
    
        private func stop() {
            player.stop()
            print("Stoping metronome")
        }
    
        private func tick() {
            guard
                enabled,
                nextTick <= DispatchTime.now()
                else { return }
    
            let interval: TimeInterval = 60.0 / TimeInterval(bpm)
            nextTick = nextTick + interval
            DispatchQueue.main.asyncAfter(deadline: nextTick) { [weak self] in
                self?.tick()
            }
    
            player.play(atTime: interval)
            onTick?(nextTick)
        }
    }
    

    ViewController.swift

    import UIKit
    
    class ViewController: UIViewController {
    
        @IBOutlet weak var bpmLabel: UILabel!
        @IBOutlet weak var tickLabel: UILabel!
    
        let myMetronome = Metronome()
    
        override func viewWillAppear(_ animated: Bool) {
            super.viewWillAppear(animated)
    
            myMetronome.onTick = { (nextTick) in
                self.animateTick()
            }
            updateBpm()
        }
    
        private func animateTick() {
            tickLabel.alpha = 1.0
            UIView.animate(withDuration: 0.35) {
                self.tickLabel.alpha = 0.0
            }
        }
    
        @IBAction func startMetronome(_: Any?) {
            myMetronome.enabled = true
        }
    
        @IBAction func stopMetronome(_: Any?) {
            myMetronome.enabled = false
        }
    
        @IBAction func increaseBpm(_: Any?) {
            myMetronome.bpm += 1.0
            updateBpm()
        }
        @IBAction func decreaseBpm(_: Any?) {
            myMetronome.bpm -= 1.0
            updateBpm()
        }
    
        private func updateBpm() {
            let metronomeBpm = Int(myMetronome.bpm)
            bpmLabel.text = "\(metronomeBpm)"
        }
    }
    

    Note: There seems to be a pre-loading issue, the prepareToPlay() doesn't fully load the audio file before playing and it causes some timing issue with the first playback of the tick audio file. This issue will be left to the reader to figure out. The original question being synchronization, this should be demonstrated in the code above.