0

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)"
    }
}
}
RAM
  • 119
  • 1
  • 14
  • There are no “visuals” in your code. So it’s not clear what the question is about. – matt Jul 18 '18 at 12:45
  • Hi i have edited the code pls check once **(callbackBeat % 4) + 1** is the beat i need to display in my view. – RAM Jul 18 '18 at 12:49
  • But you are not showing any code that displays anything in any view. And the code you have marked Visuals is on a background thread with a delay so of _course_ there is a delay. – matt Jul 18 '18 at 12:57
  • @matt i have implemented the delegate method in my view controller. ** metronomeTicking** will be called in my view controller on there i'm showing the animations which appears with an delay. – RAM Jul 18 '18 at 13:06
  • Let me say it louder. _Show your code._ We can't help with code we can't see. What we _can_ see is a delay that _you_ are putting there, so it is totally expected. – matt Jul 18 '18 at 13:10
  • Thanks @matt. i have updated the code with my view controller functions on where i'm showing the beat count. its just an simple label . which needs to print the current beat. pls check the code now. – RAM Jul 18 '18 at 14:36
  • 2
    I still don't understand the point of saying `DispatchQueue.global(qos: .userInitiated)`. This puts you on a background thread and gives the runtime permission to execute your code whenever it feels like it. This seems to me to be exactly the _opposite_ of what you want to do. Surely if you want the lowest latency with the highest accuracy, you want to get on the _main_ thread. Of course I still can't guarantee the accuracy of `asyncAfter`; that seems to me another source of risk. Ideally you should "tick" directly, exactly when the sound plays. – matt Jul 18 '18 at 14:39
  • @matt thanks . this is my code repo https://github.com/ramlearn77/metrolearn . if you run and see for bpm less than 80 the beat visuals are not synchronised properly but for higher bpm everything works gr8 – RAM Jul 19 '18 at 14:32
  • But you didn’t do what I said. – matt Jul 20 '18 at 12:59

1 Answers1

1

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.

ekscrypto
  • 3,718
  • 1
  • 24
  • 38
  • @RAM you need to also include how your visual animation gets started – ekscrypto Jul 18 '18 at 13:09
  • Thanks, i have updated the code with my viewcontroller functions.Pls kindly check it. – RAM Jul 18 '18 at 14:35
  • 1
    @Ram see my updated answer with a git repo to match. Cheers! – ekscrypto Jul 18 '18 at 15:47
  • Thanks, i have checked it but the view controller code is empty – RAM Jul 19 '18 at 04:59
  • @RAM Oops, sorry forgot to push my commit. Check it out now! :) – ekscrypto Jul 19 '18 at 05:02
  • Thanks, i have created an sample app according to my current requirements https://github.com/ramlearn77/metrolearn . if you run the app you can see that when the bpm is less than 80 the beat number appears first and then the sound will be player but in higher bpm both sound and beat visual will be played at correct time. can you please check it once . – RAM Jul 19 '18 at 14:31
  • @RAM It's unclear what the status of the question is at this point. If this answer solves the problem, the question-answer cycle is over, so what is your sample app for? If it doesn't, why did you accept it? – matt Jul 19 '18 at 14:46
  • @matt i'm really sorry that your answer didn't worked when i used with sound buffers to play two audio files, but it will help for those who use audio player to play only single metronome sound. did you got any chance to check my repo ?? – RAM Jul 20 '18 at 05:02
  • It’s not my answer. You have accepted an answer. The question is therefore over. – matt Jul 20 '18 at 10:46
  • @RAM I checked your repo. You use a DispatchQueue.global(qos: .userInitiated) for starting the sound, there isn't any "heavy" work being performed so change this to a main queue. Then, once you call the metronomeTicking delegate method, you no longer need to dispatch an ASYNC to the main queue. That's where the lag comes from. – ekscrypto Jul 20 '18 at 11:17
  • Thanks @ekscrypto . i will try this now – RAM Jul 21 '18 at 11:41
  • @ekscrypto I just tried it its still the same an delay. what happens is the visual beat and sound are not synchronised the visual comes once then the sound play through the device,but it should be like both visual and sound should start at same time ?. Any idea for fixing it ? – RAM Jul 23 '18 at 10:20
  • @ekscrypto This is the Url from apple which I have referred https://developer.apple.com/library/archive/samplecode/HelloMetronome/Listings/iOSHelloMetronome_Metronome_swift.html#//apple_ref/doc/uid/TP40017587-iOSHelloMetronome_Metronome_swift-DontLinkElementID_12 . can you please let me know where I'm wrong . Thanks – RAM Jul 23 '18 at 12:24
  • @RAM I'm sorry this is starting to take more time than I can offer. If you have an Apple developer account, which I suspect you do, you can ask for code-level assistance from an Apple engineer. – ekscrypto Jul 23 '18 at 13:03