Skip to content

Instantly share code, notes, and snippets.

@cemolcay
Last active December 2, 2018 21:42
Show Gist options
  • Save cemolcay/3c9badfa263888d686e3aa454a5adfb7 to your computer and use it in GitHub Desktop.
Save cemolcay/3c9badfa263888d686e3aa454a5adfb7 to your computer and use it in GitHub Desktop.
Swift 4.0 port for LinkKit iOS of Ableton Link
//
// ABLLinkManager.swift
//
// Created by Cem Olcay on 5.03.2018.
// Copyright © 2018 cemolcay. All rights reserved.
//
import Foundation
import AVFoundation
// MARK: - Structs
/// Engine-related data that can be changed from the main thread.
public struct ABLEngineData {
/// Hardware output latency in HostTime
public var outputLatency: UInt32
public var resetToBeatTime: Float64
public var proposeBpm: Float64
public var quantum: Float64
public var requestStart: Bool
public var requestStop: Bool
public init(
outputLatency: UInt32 = 0,
resetToBeatTime: Float64 = 0,
proposeBpm: Float64 = 120,
quantum: Float64 = 4,
requestStart: Bool = false,
requestStop: Bool = false) {
self.outputLatency = outputLatency
self.resetToBeatTime = resetToBeatTime
self.proposeBpm = proposeBpm
self.quantum = quantum
self.requestStart = requestStart
self.requestStop = requestStop
}
}
/// Structure that stores all data needed by the audio callback.
public struct ABLLinkData {
public var linkRef: ABLLinkRef
/// Shared between threads. Only write when engine not running.
public var sampleRate: Float64
/// Shared between threads. Only write when engine not running.
public var secondsToHostTime: Float64
/// Shared between threads. Written by the main thread and only read by the audio thread when doing so will not block.
public var sharedEngineData: ABLEngineData
/// Copy of sharedEngineData Aowned by audio thread.
public var localEngineData: ABLEngineData
/// Owned by audio thread
public var timeAtLastClick: UInt64
/// Owned by audio thread
public var isPlaying: Bool
public init(
linkRef: ABLLinkRef,
sampleRate: Float64,
secondsToHostTime: Float64,
sharedEngineData: ABLEngineData,
localEngineData: ABLEngineData,
timeAtLastClick: UInt64,
isPlaying: Bool) {
self.linkRef = linkRef
self.sampleRate = sampleRate
self.secondsToHostTime = secondsToHostTime
self.sharedEngineData = sharedEngineData
self.localEngineData = localEngineData
self.timeAtLastClick = timeAtLastClick
self.isPlaying = isPlaying
}
}
// MARK: - Listeners
public typealias ABLLinkManagerTempoCallback = (_ bpm: Double, _ quantum: Double) -> Void
public typealias ABLLinkManagerActivationCallback = (_ isEnabled: Bool) -> Void
public typealias ABLLinkManagerConnectionCallback = (_ isConnected: Bool) -> Void
public enum ABLLinkManagerListenerType {
case tempo(ABLLinkManagerTempoCallback)
case activation(ABLLinkManagerActivationCallback)
case connection(ABLLinkManagerConnectionCallback)
}
public struct ABLLinkManagerListener: Equatable {
public private(set) var id: String
public private(set) var type: ABLLinkManagerListenerType
public init(type: ABLLinkManagerListenerType) {
self.id = UUID().uuidString
self.type = type
}
// MARK: Equatable
public static func ==(lhs: ABLLinkManagerListener, rhs: ABLLinkManagerListener) -> Bool {
return lhs.id == rhs.id
}
}
// MARK: - Manager
public class ABLLinkManager: NSObject {
public static let shared = ABLLinkManager()
// Constants
public static let INVALID_BEAT_TIME: Double = Double.leastNormalMagnitude
public static let INVALID_BPM: Double = Double.leastNormalMagnitude
public static let QUANTUM_DEFAULT: Float64 = 4
// Variables
// var lock = os_unfair_lock() //ios10
private var lock = os_unfair_lock()
private var linkData: ABLLinkData?
// Debug
public var isDebugging: Bool = false
// Listeners
private var listeners = [ABLLinkManagerListener]()
// MARK: Init
private override init() {
super.init()
}
deinit {
if let linkData = linkData {
// Deletes Link (don't have multiples of this). Do this during app shutdown
ABLLinkDelete(linkData.linkRef)
}
}
// MARK: Public API
/// Reference of Link itself.
public var linkRef: ABLLinkRef? {
return linkData?.linkRef
}
/// Detemines if Link is connected or not.
public var isConnected: Bool {
guard let ref = linkData?.linkRef else { return false }
return ABLLinkIsConnected(ref)
}
/// Determines if Link is enabled or not.
public var isEnabled: Bool {
guard let linkRef = linkRef else { return false }
return ABLLinkIsEnabled(linkRef)
}
/// Detemines if Link is playing or not.
public private(set) var isPlaying: Bool {
get {
guard let linkRef = linkRef,
let sessionState = ABLLinkCaptureAppSessionState(linkRef)
else { return false }
return ABLLinkIsPlaying(sessionState)
} set {
guard var linkData = linkData else { return }
os_unfair_lock_lock(&lock)
if newValue { // isPlaying
linkData.sharedEngineData.requestStart = newValue
} else {
linkData.sharedEngineData.requestStop = newValue
}
self.linkData = linkData
os_unfair_lock_unlock(&lock)
}
}
/// Beats per minute.
public var bpm: Float64 {
get {
guard let linkRef = linkRef else { return ABLLinkManager.INVALID_BPM }
return ABLLinkGetTempo(ABLLinkCaptureAppSessionState(linkRef))
} set {
guard var linkData = linkData else {
debugMessage("ABL: LinkData invalid when trying to set BPM")
return
}
debugMessage("ABL: Set Bpm to", newValue)
os_unfair_lock_lock(&lock)
linkData.sharedEngineData.proposeBpm = newValue
self.linkData = linkData
os_unfair_lock_unlock(&lock)
}
}
/// Current beat.
public var beatTime: Float64 {
guard let linkRef = linkRef else {
debugMessage("ABL: LinkData invalid when trying to get beat. Returning 0.")
return 0
}
return ABLLinkBeatAtTime(
ABLLinkCaptureAppSessionState(linkRef),
mach_absolute_time(),
quantum)
}
/// Current quantum.
public var quantum: Float64 {
get {
guard let linkData = linkData else {
debugMessage("ABL: LinkData invalid when trying to get quantum. Returning default.")
return ABLLinkManager.QUANTUM_DEFAULT
}
return linkData.sharedEngineData.quantum
} set {
guard var linkData = linkData else { return }
os_unfair_lock_lock(&lock)
linkData.sharedEngineData.quantum = newValue
self.linkData = linkData
os_unfair_lock_unlock(&lock)
}
}
/// Returns Link settings view controller initilized with Link reference.
public var settingsViewController: ABLLinkSettingsViewController? {
guard let linkData = linkData else {
debugMessage("ABL: Error casting ABL vc as UIViewController")
return nil
}
return ABLLinkSettingsViewController.instance(linkData.linkRef)
}
/// Initilizes Link with tempo and quantum.
///
/// - Parameters:
/// - bpm: Tempo.
/// - quantum: Quantum.
public func setup(bpm: Double, quantum: Float64) {
debugMessage("ABL: Init")
var timeInfo = mach_timebase_info_data_t()
mach_timebase_info(&timeInfo)
// Create Link (don't have multiple instances)
// Always initialized with a tempo, even if just a default
// Use app tempo unless there is an existing tempo from the network
let linkRef: ABLLinkRef = ABLLinkNew(bpm)
let sharedEngineData = ABLEngineData()
let localEngineData = ABLEngineData()
linkData = ABLLinkData(
linkRef: linkRef,
sampleRate: AVAudioSession.sharedInstance().sampleRate,
secondsToHostTime: (1.0e9 * Float64(timeInfo.denom)) / Float64(timeInfo.numer),
sharedEngineData: sharedEngineData,
localEngineData: localEngineData,
timeAtLastClick: 0,
isPlaying: false)
addListeners()
}
// MARK: Listeners
/// Add listeners to subscribe changes. Don't forget to keep a reference of your listener and remove it after you're done.
///
/// - Parameter type: Listener type with callback.
/// - Returns: Listener reference that you can unsubscribe later.
@discardableResult public func add(listener type: ABLLinkManagerListenerType) -> ABLLinkManagerListener {
let listener = ABLLinkManagerListener(type: type)
listeners.append(listener)
return listener
}
/// Unsubscribes your listener after you're done.
///
/// - Parameter listener: Listener you want to remove.
/// - Returns: Returns result of the operation.
@discardableResult public func remove(listener: ABLLinkManagerListener) -> Bool {
guard let index = listeners.index(of: listener) else { return false }
listeners.remove(at: index)
return true
}
/// Removes all listeners.
public func removeAllListeners() {
listeners = []
}
// MARK: Update
// Metronome loop sub function
private func updatedEngineData() -> ABLEngineData? {
guard var linkData = linkData else { return nil }
//create new engine object with generic values
var output = ABLEngineData()
// Always reset the signaling members to their default state
output.resetToBeatTime = ABLLinkManager.INVALID_BEAT_TIME
output.proposeBpm = ABLLinkManager.INVALID_BPM
output.requestStart = false
output.requestStop = false
// Attempt to grab the lock guarding the shared engine data but
// don't block if we can't get it.
if os_unfair_lock_trylock(&lock) {
// Copy non-signaling members to the local thread cache
linkData.localEngineData.outputLatency = linkData.sharedEngineData.outputLatency
linkData.localEngineData.quantum = linkData.sharedEngineData.quantum
// Copy signaling members directly to the output and reset
output.resetToBeatTime = linkData.sharedEngineData.resetToBeatTime
linkData.sharedEngineData.resetToBeatTime = ABLLinkManager.INVALID_BEAT_TIME
output.requestStart = linkData.sharedEngineData.requestStart
linkData.sharedEngineData.requestStart = false
output.requestStop = linkData.sharedEngineData.requestStop
linkData.sharedEngineData.requestStop = false
output.proposeBpm = linkData.sharedEngineData.proposeBpm
linkData.sharedEngineData.proposeBpm = ABLLinkManager.INVALID_BPM
self.linkData = linkData
os_unfair_lock_unlock(&lock)
}
// Copy from the thread local copy to the output. This happens
// whether or not we were able to grab the lock.
output.outputLatency = linkData.localEngineData.outputLatency
output.quantum = linkData.localEngineData.quantum
if output.proposeBpm != ABLLinkManager.INVALID_BEAT_TIME {
debugMessage("ABL: output propose bpm = ", output.proposeBpm)
}
return output
}
public func update() {
guard var linkData = linkData,
let sessionState = ABLLinkCaptureAudioSessionState(linkData.linkRef),
let engineData = updatedEngineData() // update engine data
else { return }
// The mHostTime member of the timestamp represents the time at
// which the buffer is delivered to the audio hardware. The output
// latency is the time from when the buffer is delivered to the
// audio hardware to when the beginning of the buffer starts
// reaching the output. We add those values to get the host time
// at which the first sample of this buffer will reach the output.
let hostTimeAtBufferBegin: UInt64 = mach_absolute_time() + UInt64(engineData.outputLatency)
if engineData.requestStart && !ABLLinkIsPlaying(sessionState) {
// Request starting playback at the beginning of this buffer.
ABLLinkSetIsPlaying(sessionState, true, hostTimeAtBufferBegin)
}
if engineData.requestStop && ABLLinkIsPlaying(sessionState) {
// Request stopping playback at the beginning of this buffer.
ABLLinkSetIsPlaying(sessionState, false, hostTimeAtBufferBegin)
}
if !linkData.isPlaying && ABLLinkIsPlaying(sessionState) {
// Reset the session state's beat timeline so that the requested
// beat time corresponds to the time the transport will start playing.
// The returned beat time is the actual beat time mapped to the time
// playback will start, which therefore may be less than the requested
// beat time by up to a quantum.
ABLLinkRequestBeatAtStartPlayingTime(sessionState, 0, engineData.quantum)
linkData.isPlaying = true
} else if linkData.isPlaying && !ABLLinkIsPlaying(sessionState) {
linkData.isPlaying = false
}
// Handle a tempo proposal
if engineData.proposeBpm != ABLLinkManager.INVALID_BPM {
// Propose that the new tempo takes effect at the beginning of this buffer.
ABLLinkSetTempo(sessionState, engineData.proposeBpm, hostTimeAtBufferBegin)
debugMessage("ABL: Proposed BPM = ", engineData.proposeBpm)
}
//post the current position after doing the updates
ABLLinkCommitAudioSessionState(linkData.linkRef, sessionState)
self.linkData = linkData
debugMessage("ABL: Current beat = ", beatTime)
}
// MARK: Listeners
private func addListeners() {
// Route change
NotificationCenter.default.addObserver(
self,
selector: #selector(handleRouteChange),
name: NSNotification.Name.AVAudioSessionRouteChange,
object: AVAudioSession.sharedInstance())
guard let ref = linkData?.linkRef else {
debugMessage("ABL: Error getting linkRef when adding listeners")
return
}
// Void pointer to self for C callbacks below
// http://stackoverflow.com/questions/33260808/swift-proper-use-of-cfnotificationcenteraddobserver-w-callback
let selfAsURP = UnsafeRawPointer(Unmanaged.passUnretained(self).toOpaque())
let selfAsUMRP = UnsafeMutableRawPointer(mutating:selfAsURP)
// Add listerner to detect tempo changes from other devices
ABLLinkSetSessionTempoCallback(ref, { sessionTempo, context in
if let context = context {
let localSelf = Unmanaged<ABLLinkManager>.fromOpaque(context).takeUnretainedValue()
let localSelfAsUMRP = UnsafeMutableRawPointer(mutating:context)
localSelf.onSessionTempoChanged(bpm: sessionTempo, context: localSelfAsUMRP)
}
}, selfAsUMRP)
ABLLinkSetIsEnabledCallback(ref, { isEnabled, context in
if let context = context {
let localSelf = Unmanaged<ABLLinkManager>.fromOpaque(context).takeUnretainedValue()
let localSelfAsUMRP = UnsafeMutableRawPointer(mutating:context)
localSelf.onLinkEnabled(isEnabled: isEnabled, context: localSelfAsUMRP)
}
}, selfAsUMRP)
ABLLinkSetIsConnectedCallback(ref, { isConnected, context in
if let context = context {
let localSelf = Unmanaged<ABLLinkManager>.fromOpaque(context).takeUnretainedValue()
let localSelfAsUMRP = UnsafeMutableRawPointer(mutating:context)
localSelf.onConnectionStatusChanged(isConnected: isConnected, context: localSelfAsUMRP)
}
}, selfAsUMRP)
}
// Route change
@objc internal func handleRouteChange() {
guard var linkData = linkData else {
debugMessage("ABL: Error accesing LinkData during route change")
return
}
let outputLatency: UInt32 = UInt32(linkData.secondsToHostTime * AVAudioSession.sharedInstance().outputLatency)
os_unfair_lock_lock(&lock)
linkData.sharedEngineData.outputLatency = outputLatency
self.linkData = linkData
os_unfair_lock_unlock(&lock)
debugMessage("ABL: Route change")
}
// Tempo changes from other Link devices
private func onSessionTempoChanged(bpm: Double, context: Optional<UnsafeMutableRawPointer>) {
debugMessage("ABL: onSessionTempoChanged")
//update local var
self.bpm = bpm
debugMessage("ABL: curr bpm", bpm)
// Inform listeners
for listener in listeners {
if case .tempo(let callback) = listener.type {
callback(bpm, quantum)
}
}
}
// On Link enabled
private func onLinkEnabled(isEnabled: Bool, context: Optional<UnsafeMutableRawPointer>) {
debugMessage("ABL: Link is", isEnabled)
// Inform listeners
for listener in listeners {
if case .activation(let callback) = listener.type {
callback(isEnabled)
}
}
}
// Connection Status from ther devices changed
private func onConnectionStatusChanged(isConnected: Bool, context: Optional<UnsafeMutableRawPointer>) -> (){
debugMessage("ABL: onConnectionStatusChanged: isConnected = ", isConnected)
// Inform listeners
for listener in listeners {
if case .connection(let callback) = listener.type {
callback(isConnected)
}
}
}
// MARK: Utils
private func debugMessage(_ message: Any ...) {
if isDebugging {
print(message)
}
}
}
@cemolcay
Copy link
Author

cemolcay commented Nov 20, 2017

Example usage:

override func viewDidLoad() {
  super.viewDidLoad()

  // Setup Link
  ABLLinkManager.shared.setup(bpm: 120, quantum: ABLLinkManager.QUANTUM_DEFAULT)

  // Subscribe tempo change events
  ABLLinkManager.shared.add(listener: .tempo({ bpm, quantum in
    self.tempo.bpm = bpm
  }))

  // Subscribe activation events
  ABLLinkManager.shared.add(listener: .activation({ isActivated in
    self.updateUI()
  }))

  // Subscribe connection events.
  ABLLinkManager.shared.add(listener: .connection({ isConnected in
    if isConnected {
      ABLLinkManager.shared.start()
    } else {
      ABLLinkManager.shared.stop()
    }
  }))
}

// Update Link tempo
@IBAction func tempoDidChange(sender: UIControl) { 
  ABLLinkManager.shared.bpm = tempo.bpm
}

deinit {
  ABLLinkManager.shared.shutdown()
}

@luismartimor
Copy link

luismartimor commented May 20, 2018

I'm getting this error: " Value of type 'ABLLinkManager' has no member 'start' "

Removed the start() function in the last revision. So the timer should be in the superview. Please, update the example.
Thanks, good work.

@luismartimor
Copy link

Hi Celmolcay, good work.

You can Add this code to request Start / Stop

public func requestStart(_ b: Bool = true) {
    guard var linkData = linkData else { return }
    os_unfair_lock_lock(&lock)
    linkData.sharedEngineData.requestStart = b
    linkData.sharedEngineData.requestStop = false
    self.linkData = linkData
    os_unfair_lock_unlock(&lock)
}
public func requestStop(_ b: Bool = true) {
    guard var linkData = linkData else { return }
    os_unfair_lock_lock(&lock)
    linkData.sharedEngineData.requestStop = b
    linkData.sharedEngineData.requestStart = false
    self.linkData = linkData
    os_unfair_lock_unlock(&lock)
}

I realized latency is not always correct, resulting lack of accuracy ... any idea how to improve this?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment