Swift 可靠的节拍器系统

Swift Solid Metronome System

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

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

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

更新

因此,在我上次收到反馈后,此时我已经实施并清理了。此时这里是我的代码的结构。它正在播放。但一开始我仍然快速点击 2 次,然后就稳定下来了。

对于我对这件事的无知,我深表歉意。我希望我走在正确的道路上。

我目前也在制作另一种方法的原型。在我有一个非常小的音频文件的地方,单击一下就死了 space 在它的末尾具有正确的持续时间,直到特定节奏的循环点。我正在循环播放它并且效果很好。但唯一的事情是我没有检测到视觉更新的循环点,所以我有我的基本 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
        }

    }

纯粹用 NSTimer 构建的节拍器不会很准确,正如 Apple 在他们的文档中解释的那样。

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.

我建议使用一个 NSTimer,它在每个所需的滴答声中触发大约 50 次(例如,如果您想要每分钟 60 个滴答声,您可以使用 NSTimeInterval大约是 1/50 秒。

然后您应该存储一个 CFAbsoluteTime 存储 "last tick" 时间,并将其与当前时间进行比较。如果当前时间与 "last tick" 时间之差的绝对值小于某个公差(我会将其设为每个时间间隔的刻度数的 4 倍,例如,如果您选择 a 的 1/50每 NSTimer 触发一次,你应该应用大约 4/50 秒的容差),你可以播放 "tick."

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

这里有一些关于 another SO post 的更多信息。它还包括一些使用我讨论的理论的代码。希望对您有所帮助!

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

这是我的计时器 "tick" 函数,它似乎工作正常。您需要对其进行调整以满足您的需求(使用强拍等),但它在概念上是可行的。

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)

好的!你不能根据时间把事情做好,因为我们需要以某种方式处理 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()

        }

    }

感谢 vigneshv & CakeGamesStudios 在这个问题上所做的出色工作,我能够汇总以下内容,这是此处讨论的节拍器计时器的扩展版本。 一些亮点:

  • 更新为 Swift v5
  • 它使用 Grand Central Dispatch 计时器 运行 在单独的队列上,而不是仅仅使用常规的 NSTimer(有关详细信息,请参阅 here
  • 为了清晰起见,它使用了更多的计算属性
  • 它使用委托,允许委托 class 处理任意 'tick' 操作(从 AVFoundation 播放声音、更新显示或其他任何操作 - 请记住在创建计时器后设置委托 属性)。该代表也将是区分节拍 1 与其他节拍的人,但如果需要,可以很容易地在 class 本身中添加。
  • 它有一个 % 到 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: 
            delegate?.bpmTimerTicked() // Have the delegate respond accordingly
        }
    }

    // MARK: - Deinitialization

    deinit {

        cancelTimer() // Ensure that the timer's cancelled if this object is deallocated
    }
}