快速固体节拍器系统

时间:2015-11-20 05:12:49

标签: swift timer nstimer

我正在尝试构建一个可靠的固体系统,使用SWIFT在我的应用程序中构建一个节拍器。

到目前为止,我已经构建了使用NSTimer的可靠系统。我现在唯一的问题是当计时器启动时,前2次点击是关闭时间,但随后它会进入稳固的时间范围。

现在经过我所有的研究后,我看到人们提到你应该使用其他不依赖于NSTimer的音频工具..或者如果你选择使用NSTimer,那么它应该在它自己的线程上。现在我看到很多人对此感到困惑,包括我自己,我希望能够深入到这个节拍器业务的底层并解决这个问题并与所有挣扎的人分享。

更新

所以我在上次收到的反馈后,已经实施并清理了。在这一点上,这是我的代码的结构。它的回放。但是我仍然在开始时获得2次快速点击,然后它就会安顿下来。

我对这个无知感到抱歉。我希望我走在正确的道路上。

我目前正在制作另一种方法原型。我有一个非常小的音频文件,只需一次点击,并在其末尾有一个死区,持续时间正确,直到特定节奏的循环点。我正在循环这个并且工作得非常好。但唯一的问题是我没有检测到视觉更新的循环点,所以我让我的基本NStimer只检测正在处理的音频下面的时间间隔,它似乎在整个过程中非常好地匹配并且没有延迟。但是我仍然宁愿用这个NSTimer来完成它。如果你能够轻松发现我的错误,那将是一个很好的方向,并确保它可以很快工作!非常感谢。

    //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
        }

    }

3 个答案:

答案 0 :(得分:7)

纯粹使用NSTimer构建的节拍器将不会非常准确,正如Apple在其文档中所解释的那样。

  

由于典型的运行循环管理各种输入源,因此定时器的时间间隔的有效分辨率限制在50-100毫秒的量级。如果在长时间标注期间或在运行循环处于不监视计时器的模式下发生计时器的触发时间,则在下次运行循环检查计时器之前,计时器不会触发。

我建议使用一个NSTimer,按照每个所需的刻度点击50次(例如,如果你想要每分钟60个刻度,那么NSTimeInterval就是{ 1/50秒。

然后你应该存储一个CFAbsoluteTime来存储“最后一个滴答”时间,然后将它与当前时间进行比较。如果当前时间和“最后一次滴答”时间之间的差值的绝对值小于某个容差(例如,如果您选择1/50秒,我会将此值设为每个间隔的滴答数的4倍左右)根据NSTimer火灾,您应该应用大约4/50秒的容差),您可以打“滴答”。

您可能需要校准公差以达到所需的精度,但这个一般概念将使您的节拍器更准确。

以下是有关another SO post的更多信息。它还包括一些使用我讨论过的理论的代码。我希望这有帮助!

<强>更新 计算公差的方式不正确。在计算中,请注意公差与bpm的平方成反比。这个问题是容差最终会小于计时器每秒触发的次数。看看this graph,看看我的意思。这将在高BPM时产生问题。另一个潜在的错误来源是您的最高限制条件。你真的不需要检查容差的上限,因为理论上,计时器应该已经被解雇了。因此,如果经过的时间大于理论时间,则无论如何都可以触发。 (例如,如果经过的时间是0.1秒,并且真实BPM的实际时间应该是0.05秒,那么无论你的公差是多少,都应该继续发射计时器。)

这是我的计时器“tick”功能,似乎工作正常。你需要调整它以满足你的需求(使用downbeats等),但它在概念上有效。

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
    }
}

我的计时器初始化如下:nextTimer = NSTimer(timeInterval: (60.0/Double(bpm)) * 0.01, target: self, selector: "tick:", userInfo: ["bpm":bpm], repeats: true)

答案 1 :(得分:3)

确定!你无法根据时间做正确的事情,因为我们需要以某种方式处理DA转换器及其频率 - 采样率。我们需要告诉他们开始播放声音的确切样本。添加一个视图iOS应用程序,启动和停止两个按钮,并将此代码插入ViewController.swift。我保持简单,这只是我们如何做到这一点的想法。对不起强迫尝试...这个是用swift 3制作的。另外在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()

        }

    }

答案 2 :(得分:1)

感谢vigneshvCakeGamesStudios在此问题上所做的出色工作,我得以将以下内容汇总在一起,这是这里讨论的节拍器计时器的扩展版本。 一些亮点:

  • 它已针对 Swift v5
  • 更新
  • 它使用Grand Central Dispatch计时器在单独的队列上运行,而不是仅在常规NSTimer上运行(有关更多详细信息,请参见here
  • 为清楚起见,它使用了更多的计算属性
  • 它使用委托,以允许委托类处理任何任意的“滴答”动作(例如,播放来自AVFoundation的声音,更新显示或进行其他操作-只需记住在创建了计时器)。这位代表也是区分节拍1与其他的人,但是如果需要的话,可以很容易地将其添加到此类中。
  • 它具有“%to Next Tick”属性,可用于更新UI进度栏等。

欢迎就如何进一步改善此问题提供任何反馈!

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
    }
}