Last active
October 21, 2024 17:56
-
-
Save cemolcay/3c9badfa263888d686e3aa454a5adfb7 to your computer and use it in GitHub Desktop.
Swift 4.0 port for LinkKit iOS of Ableton Link
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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) | |
} | |
} | |
} |
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.
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
Example usage: