Swift Solid Metronome System

前端 未结 3 472
北恋
北恋 2020-12-28 23:30

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

相关标签:
3条回答
  • 2020-12-29 00:12

    Ok! You can't get things right basing on time, because somehow we need to deal with DA converters and their frequency - samplerate. We need to tell them the exact sample to start play the sound. Add a single view iOS app with two buttons start and stop and insert this code into ViewController.swift. I keep things simple and it's just an Idea of how we can do this. Sorry for forcing try... This one is made with swift 3. Also check out my project on GitHub https://github.com/AlexShubin/MetronomeIdea

    Swift 3

       import UIKit
        import AVFoundation
    
        class Metronome {
    
            var audioPlayerNode:AVAudioPlayerNode
            var audioFile:AVAudioFile
            var audioEngine:AVAudioEngine
    
            init (fileURL: URL) {
    
                audioFile = try! AVAudioFile(forReading: fileURL)
    
                audioPlayerNode = AVAudioPlayerNode()
    
                audioEngine = AVAudioEngine()
                audioEngine.attach(self.audioPlayerNode)
    
                audioEngine.connect(audioPlayerNode, to: audioEngine.mainMixerNode, format: audioFile.processingFormat)
                try! audioEngine.start()
    
            }
    
            func generateBuffer(forBpm bpm: Int) -> AVAudioPCMBuffer {
                audioFile.framePosition = 0
                let periodLength = AVAudioFrameCount(audioFile.processingFormat.sampleRate * 60 / Double(bpm))
                let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: periodLength)
                try! audioFile.read(into: buffer)
                buffer.frameLength = periodLength
                return buffer
            }
    
            func play(bpm: Int) {
    
                let buffer = generateBuffer(forBpm: bpm)
    
       self.audioPlayerNode.play()
    
                self.audioPlayerNode.scheduleBuffer(buffer, at: nil, options: .loops, completionHandler: nil)
    
    
    
            }
    
            func stop() {
    
                audioPlayerNode.stop()
    
            }
    
        }
    
    
        class ViewController: UIViewController {
    
            var metronome:Metronome
    
            required init?(coder aDecoder: NSCoder) {
    
                let fileUrl = Bundle.main.url(forResource: "Click", withExtension: "wav")
    
                metronome = Metronome(fileURL: fileUrl!)
    
                super.init(coder: aDecoder)
    
            }
    
            @IBAction func StartPlayback(_ sender: Any) {
    
                metronome.play(bpm: 120)
    
            }
    
            @IBAction func StopPlayback(_ sender: Any) {
    
                metronome.stop()
    
            }
    
        }
    
    0 讨论(0)
  • 2020-12-29 00:15

    Thanks to the great work already done on this question by vigneshv & CakeGamesStudios, I was able to put together the following, which is an expanded version of the metronome timer discussed here. Some highlights:

    • It's updated for Swift v5
    • It uses a Grand Central Dispatch timer to run on a separate queue, rather than just a regular NSTimer (see here for more details)
    • It uses more calculated properties for clarity
    • It uses delegation, to allow for any arbitrary 'tick' action to be handled by the delegate class (be that playing a sound from AVFoundation, updating the display, or whatever else - just remember to set the delegate property after creating the timer). This delegate would also be the one to distinguish beat 1 vs. others, but that'd be easy enough to add within this class itself if desired.
    • It has a % to Next Tick property, which could be used to update a UI progress bar, etc.

    Any feedback on how this can be improved further is welcome!

    protocol BPMTimerDelegate: class {
        func bpmTimerTicked()
    }
    
    class BPMTimer {
    
        // MARK: - Properties
    
        weak var delegate: BPMTimerDelegate? // The class's delegate, to handle the results of ticks
        var bpm: Double { // The speed of the metronome ticks in BPM (Beats Per Minute)
            didSet {
                changeBPM() // Respond to any changes in BPM, so that the timer intervals change accordingly
            }
        }
        var tickDuration: Double { // The amount of time that will elapse between ticks
            return 60/bpm
        }
        var timeToNextTick: Double { // The amount of time until the next tick takes place
            if paused {
                return tickDuration
            } else {
                return abs(elapsedTime - tickDuration)
            }
        }
        var percentageToNextTick: Double { // Percentage progress from the previous tick to the next
            if paused {
                return 0
            } else {
                return min(100, (timeToNextTick / tickDuration) * 100) // Return a percentage, and never more than 100%
            }
        }
    
        // MARK: - Private Properties
    
        private var timer: DispatchSourceTimer!
        private lazy var timerQueue = DispatchQueue.global(qos: .utility) // The Grand Central Dispatch queue to be used for running the timer. Leverages a global queue with the Quality of Service 'Utility', which is for long-running tasks, typically with user-visible progress. See here for more info: https://www.raywenderlich.com/5370-grand-central-dispatch-tutorial-for-swift-4-part-1-2
        private var paused: Bool
        private var lastTickTimestamp: CFAbsoluteTime
        private var tickCheckInterval: Double {
            return tickDuration / 50 // Run checks many times within each tick duration, to ensure accuracy
        }
        private var timerTolerance: DispatchTimeInterval {
            return DispatchTimeInterval.milliseconds(Int(tickCheckInterval / 10 * 1000)) // For a repeating timer, Apple recommends a tolerance of at least 10% of the interval. It must be multiplied by 1,000, so it can be expressed in milliseconds, as required by DispatchTimeInterval.
        }
        private var elapsedTime: Double {
            return CFAbsoluteTimeGetCurrent() - lastTickTimestamp // Determine how long has passed since the last tick
        }
    
        // MARK: - Initialization
    
        init(bpm: Double) {
    
            self.bpm = bpm
            self.paused = true
            self.lastTickTimestamp = CFAbsoluteTimeGetCurrent()
            self.timer = createNewTimer()
        }
    
        // MARK: - Methods
    
        func start() {
    
            if paused {
                paused = false
                lastTickTimestamp = CFAbsoluteTimeGetCurrent()
                timer.resume() // A crash will occur if calling resume on an already resumed timer. The paused property is used to guard against this. See here for more info: https://medium.com/over-engineering/a-background-repeating-timer-in-swift-412cecfd2ef9
            } else {
                // Already running, so do nothing
            }
        }
    
        func stop() {
    
            if !paused {
                paused = true
                timer.suspend()
            } else {
                // Already paused, so do nothing
            }
        }
    
        // MARK: - Private Methods
    
        // Implements timer functionality using the DispatchSourceTimer in Grand Central Dispatch. See here for more info: http://danielemargutti.com/2018/02/22/the-secret-world-of-nstimer/
        private func createNewTimer() -> DispatchSourceTimer {
    
            let timer = DispatchSource.makeTimerSource(queue: timerQueue) // Create the timer on the correct queue
            let deadline: DispatchTime = DispatchTime.now() + tickCheckInterval // Establish the next time to trigger
            timer.schedule(deadline: deadline, repeating: tickCheckInterval, leeway: timerTolerance) // Set it on a repeating schedule, with the established tolerance
            timer.setEventHandler { [weak self] in // Set the code to be executed when the timer fires, using a weak reference to 'self' to avoid retain cycles (memory leaks). See here for more info: https://learnappmaking.com/escaping-closures-swift/
                self?.tickCheck()
            }
            timer.activate() // Dispatch Sources are returned initially in the inactive state, to begin processing, use the activate() method
    
            // Determine whether to pause the timer
            if paused {
                timer.suspend()
            }
    
            return timer
        }
    
        private func cancelTimer() {
    
            timer.setEventHandler(handler: nil)
            timer.cancel()
            if paused {
                timer.resume() // If the timer is suspended, calling cancel without resuming triggers a crash. See here for more info: https://forums.developer.apple.com/thread/15902
            }
        }
    
        private func replaceTimer() {
    
            cancelTimer()
            timer = createNewTimer()
        }
    
        private func changeBPM() {
    
            replaceTimer() // Create a new timer, which will be configured for the new BPM
        }
    
        @objc private func tickCheck() {
    
            if (elapsedTime > tickDuration) || (timeToNextTick < 0.003) { // If past or extremely close to correct duration, tick
                tick()
            }
        }
    
        private func tick() {
    
            lastTickTimestamp = CFAbsoluteTimeGetCurrent()
            DispatchQueue.main.sync { // Calls the delegate from the application's main thread, because it keeps the separate threading within this class, and otherwise, it can cause errors (e.g. 'Main Thread Checker: UI API called on a background thread', if the delegate tries to update the UI). See here for more info: https://stackoverflow.com/questions/45081731/uiapplication-delegate-must-be-called-from-main-thread-only
                delegate?.bpmTimerTicked() // Have the delegate respond accordingly
            }
        }
    
        // MARK: - Deinitialization
    
        deinit {
    
            cancelTimer() // Ensure that the timer's cancelled if this object is deallocated
        }
    }
    
    0 讨论(0)
  • 2020-12-29 00:29

    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)

    0 讨论(0)
提交回复
热议问题