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
}
}
我正在尝试使用 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
}
}