) -> some View {
36 | HStack {
37 | Text(mapping.appAction.rawValue)
38 |
39 | Spacer()
40 |
41 | Text(mapping.event.name())
42 | }
43 | .background {
44 | Color(uiColor: .systemBackground)
45 | }
46 | .pressAction(onPress: {
47 | if self.actionsViewModel.midiLearn {
48 | print("select mapping")
49 | self.actionsViewModel.selectMapping(mapping)
50 | }
51 | }, onRelease: {
52 | print("deselect mapping")
53 | self.actionsViewModel.selectMapping(nil)
54 | })
55 | }
56 |
57 | @ViewBuilder func midiReceiveView() -> some View {
58 | VStack {
59 | Text("Incoming midi events")
60 | HStack {
61 | Spacer()
62 | Text(actionsViewModel.currentEvent?.name() ?? "")
63 | .font(.caption)
64 | .padding(8)
65 | Spacer()
66 | }
67 | }
68 | .background {
69 | Color.blue
70 | }
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Examples/TriggerKitDemo/TriggerKitDemo/Views/AppTabView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppTabView.swift
3 | // TriggerKitDemo
4 | //
5 | // Created by dave on 3/05/23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct AppTabView: View {
11 | var body: some View {
12 | TabView {
13 | NavigationStack {
14 | DemoView()
15 | }
16 | .tabItem {
17 | Label("Demo", systemImage: "play.circle")
18 | }
19 |
20 | NavigationStack {
21 | ActionsView()
22 | }
23 | .tabItem {
24 | Label("Mapping", systemImage: "switch.2")
25 | }
26 |
27 | NavigationStack {
28 | TKBluetoothMIDIView()
29 | }
30 | .tabItem {
31 | Label("Connect", image: "logo.bluetooth")
32 | }
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/Examples/TriggerKitDemo/TriggerKitDemo/Views/DemoView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DemoView.swift
3 | // TriggerKitDemo
4 | //
5 | // Created by dave on 4/05/23.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct DemoView: View {
11 | @EnvironmentObject var actionsViewModel: AppActionsViewModel
12 |
13 | var body: some View {
14 | VStack {
15 | Slider(value: $actionsViewModel.slider1) {
16 | Text("1")
17 | }
18 | Slider(value: $actionsViewModel.slider2) {
19 | Text("2")
20 | }
21 |
22 | Slider(value: $actionsViewModel.slider3) {
23 | Text("3")
24 | }
25 |
26 | HStack {
27 | Toggle("1", isOn: $actionsViewModel.toggle1)
28 | Spacer()
29 | .padding()
30 | Toggle("2", isOn: $actionsViewModel.toggle2)
31 | }
32 |
33 | }
34 | .navigationTitle("Demo")
35 | .padding()
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2023, David Gary Wood
4 | All rights reserved.
5 |
6 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
7 |
8 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
9 |
10 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
11 |
12 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
13 |
14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
15 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "midikit",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/orchetect/MIDIKit",
7 | "state" : {
8 | "revision" : "630e42255190701f58b373c41862796e766ff018",
9 | "version" : "0.8.9"
10 | }
11 | },
12 | {
13 | "identity" : "timecodekit",
14 | "kind" : "remoteSourceControl",
15 | "location" : "https://github.com/orchetect/TimecodeKit",
16 | "state" : {
17 | "revision" : "6f65472a671fef760ca8fb2e9ca7cc25bc220aad",
18 | "version" : "1.6.10"
19 | }
20 | }
21 | ],
22 | "version" : 2
23 | }
24 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.8
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "TriggerKit",
8 | platforms: [
9 | .macOS(.v12),
10 | .iOS(.v14),
11 | ],
12 | products: [
13 | // Products define the executables and libraries a package produces, and make them visible to other packages.
14 | .library(
15 | name: "TriggerKit",
16 | targets: ["TriggerKit"]),
17 | ],
18 | dependencies: [
19 | // Dependencies declare other packages that this package depends on.
20 | // .package(url: /* package url */, from: "1.0.0"),
21 | .package(url: "https://github.com/orchetect/MIDIKit", from: "0.8.8"),
22 | ],
23 | targets: [
24 | // Targets are the basic building blocks of a package. A target can define a module or a test suite.
25 | // Targets can depend on other targets in this package, and on products in packages this package depends on.
26 | .target(
27 | name: "TriggerKit",
28 | dependencies: [.product(name: "MIDIKit", package: "MIDIKit")]),
29 | .testTarget(
30 | name: "TriggerKitTests",
31 | dependencies: ["TriggerKit"]),
32 | ]
33 | )
34 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | # TriggerKit
4 |
5 | Bind MIDI events to code blocks in your application
6 |
7 |
8 |
9 |
10 |
11 | TriggerKit is a Swift framework for binding events to your app's code. You can use TriggerKit with iOS and iPad applications. MacOS is enabled in the Package, but not currently tested.
12 |
13 | TriggerKit is currently in Beta, and as such, may have breaking changes in future releases. When the planned event types are added, I will issue a 1.0 release.
14 |
15 | I would like to thank [Steffan Andrews](https://github.com/orchetect) for their [MIDIKit](https://github.com/orchetect/MIDIKit) library, without which TriggerKit would not be able to exist in it's current form.
16 |
17 | The key concept for TriggerKit, is that you can create codable actions for you app and events, and easily store and restore mappings for MIDI and other event types.
18 |
19 | ## Event Types supported
20 | - MIDI CC ✅
21 | - MIDI Notes ✅
22 |
23 | ## Event Types planned 🗺️
24 | - OSC
25 | - Gamepad
26 |
27 | These event types are planned as additions to TriggerKit, in future releases. Please don't hesitate to open an issue or create a PR for features you need 🙏
28 |
29 | ## Quick start 🏁
30 |
31 | - Add TriggerKit to your project via Swift Package Manager: `https://github.com/lightbeamapps/TriggerKit`
32 |
33 | - Create an enumeration of the actions your app wants to map, that conforms to TKAppActionConstraints
34 |
35 | - Instantiate the TriggerKit Bus
36 |
37 | ```swift
38 | let config = TKBusConfig(clientName: "TriggerKit", model: "TriggerKit", manufacturer: "Lightbeam Apps")
39 | let bus = TKBus(config: config)
40 | ```
41 |
42 | - Add Mappings
43 |
44 | ```swift
45 | let mapping = TKMapping(appAction: TestAcYourAppActiontion.action1, event: TKEvent.midiCC(trigger: .init(cc: 1)))
46 |
47 | bus.addMapping(mapping) { payload in
48 | // Do something!
49 | }
50 |
51 | ```
52 |
53 | You now have a TriggerKit Bus, and your first mapping 🎉!
54 |
55 | ## Usage and key concepts
56 |
57 | ### Codable
58 | Once you have created actions and mappings, you can encode these to persist them to disk and retrieve at run time.
59 |
60 | ### Learning events
61 | You can set a single callback for all events with the bus function:
62 |
63 | ```swift
64 | bus.setEventCallback() { event in
65 | // Use/persist the most recent event to update or add a mapping
66 | }
67 | ```
68 |
69 | The demo application shows an example of this.
70 |
71 | ## Examples
72 | - [TriggerKitDemo](https://github.com/lightbeamapps/TriggerKit/tree/main/Examples/TriggerKitDemo/) - a SwiftUI app that shows example mappings, and an event learn set up.
73 |
74 | ### Inbuilt views for Bluetooth midi
75 |
76 | TriggerKit has some wrappers for the CoreAudio BlueTooth MIDI detection views:
77 | - `TKBluetoothMIDIView` SwiftUI
78 | - `TKBTMIDICentralViewController` UIKit
79 |
80 | ## Contributing
81 |
82 | ### Code of Conduct and Contributing rules 🧑⚖️
83 |
84 | - Our guide to contributing is available here: [CONTRIBUTING.md](CONTRIBUTING.md).
85 | - All contributions, pull requests, and issues are expected to adhere to our community code of conduct: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
86 |
87 | ### Key contributors ⚡️
88 |
89 | #### Admin
90 | - [David Gary Wood](https://social.davidgarywood.com/@davidgarywood)
91 |
92 | ## License 📃
93 |
94 | TriggerKit is licensed with the BSD-3-Clause license, more information here: [LICENSE.MD](LICENSE.md)
95 |
96 | This is a permissive license which allows for any type of use, provided the copyright notice is included.
97 |
98 | ## Acknowledgements 🙏
99 |
100 | - MIDIKit, without which this library couldn't support MIDI Events: https://github.com/orchetect/MIDIKit
101 |
--------------------------------------------------------------------------------
/Sources/TriggerKit/Model/TKAppActionConstraints.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TKAppActionConstraints.swift
3 | //
4 | //
5 | // Created by dave on 4/05/23.
6 | //
7 |
8 | import Foundation
9 |
10 | /// App Actions supplied have to adhere to these constraints
11 | public typealias TKAppActionConstraints = Codable & CaseIterable & Hashable & Identifiable
12 |
--------------------------------------------------------------------------------
/Sources/TriggerKit/Model/TKEvent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TriggerType.swift
3 | //
4 | //
5 | // Created by dave on 3/05/23.
6 | //
7 |
8 | import Foundation
9 | import MIDIKit
10 |
11 | /// The callback executed when an event is received by the TKBus object. Typically used for event learn features in the application
12 | public typealias TKEventCallback = (TKEvent?) -> (Void)
13 |
14 | /// Wrapper for different trigger types, to transport the most recent one back for MIDI learn features
15 | public enum TKEvent: Codable, Hashable {
16 | case midiNote(trigger: TKTriggerMidiNote)
17 | case midiCC(trigger: TKTriggerMidiCC)
18 |
19 | internal static func createEventFrom(midiEvent: MIDIEvent) -> TKEvent? {
20 | switch midiEvent {
21 | case .noteOn(let noteOn):
22 | let trigger = TKTriggerMidiNote(note: Int(noteOn.note.number), noteString: noteOn.note.stringValue())
23 | return .midiNote(trigger: trigger)
24 | case .cc(let ccEvent):
25 | let trigger = TKTriggerMidiCC(cc: Int(ccEvent.controller.number))
26 | return .midiCC(trigger: trigger)
27 | default:
28 | return nil
29 | }
30 | }
31 |
32 | public func name() -> String {
33 | switch self {
34 | case .midiCC(let trigger):
35 | return "CC: \(trigger.cc)"
36 | case .midiNote(let trigger):
37 | return "Note: \(trigger.noteString), \(trigger.note)"
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Sources/TriggerKit/Model/TKPayLoad.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TriggerPayLoad.swift
3 | //
4 | //
5 | // Created by dave on 3/05/23.
6 | //
7 |
8 | import Foundation
9 |
10 | /// The callback triggered, with the payload corresponding to the event
11 | public typealias TKPayloadCallback = (TKPayLoad) -> Void
12 |
13 | public struct TKPayLoad: Codable, Hashable {
14 | /// Ranges from -1 to 1 for a gamepad axis, from 0-1.0 for CC values
15 | public var value: Double
16 |
17 | // This will carry the velocity for midi notes
18 | public var value2: Double?
19 |
20 | // Nil for CCs, the note for notes, and directly the message if available for OSC
21 | public var message: String?
22 |
23 | /// - Parameters:
24 | /// - value: the value of the event (ranging from 0.0-1.0) or nil
25 | /// - value2: the secondary value of the event (ranging from 0.0-1.0) or nil
26 | /// - message: Nil for CCs, the note for notes, and directly the message if available for OSC
27 | internal init(value: Double, value2: Double? = nil, message: String? = nil) {
28 | self.value = value
29 | self.value2 = value2
30 | self.message = message
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Sources/TriggerKit/Model/TKTriggerMidiCC.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MidiCCTrigger.swift
3 | //
4 | //
5 | // Created by dave on 3/05/23.
6 | //
7 |
8 | import Foundation
9 |
10 | /// Represents a MIDI CC trigger with a CC value
11 | public struct TKTriggerMidiCC: Codable, Hashable {
12 | /// the CC value of the midi trigger
13 | public var cc: Int
14 |
15 | /// - Parameter cc: the CC value of the midi trigger
16 | public init(cc: Int) {
17 | self.cc = cc
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Sources/TriggerKit/Model/TKTriggerMidiNote.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MidiNoteTrigger.swift
3 | //
4 | //
5 | // Created by dave on 3/05/23.
6 | //
7 |
8 | import Foundation
9 | import MIDIKit
10 |
11 | /// Represents a MIDI note trigger
12 | public struct TKTriggerMidiNote: Codable, Hashable {
13 | /// The int value of the note being triggered, e.g 62
14 | public var note: Int
15 |
16 | /// The string value fo the note being triggered, e.g. "D3"
17 | public var noteString: String
18 |
19 | /// True if the note is being held down, false if being released
20 | public var noteOn: Bool
21 |
22 | /// MIDI Note initializer
23 | /// - Parameters:
24 | /// - note: The int value of the note being triggered, e.g 62
25 | /// - noteString: The string value fo the note being triggered, e.g. "D3"
26 | /// - noteOn: True if the note is being held down, false if being released
27 | public init(note: Int, noteString: String? = nil, noteOn: Bool = true) {
28 | self.note = note
29 | if let noteString {
30 | self.noteString = noteString
31 | } else if let midiNote = try? MIDINote.init(note) {
32 | self.noteString = midiNote.stringValue()
33 | } else {
34 | self.noteString = String(note)
35 | }
36 |
37 | self.noteOn = noteOn
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/Sources/TriggerKit/TKBus.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TKBus.swift
3 | //
4 | //
5 | // Created by dave on 3/05/23.
6 | //
7 |
8 | import MIDIKitIO
9 | import Foundation
10 | import Combine
11 |
12 | /// Configuration for the TriggerKit Bus
13 | ///
14 | /// This is given to TKBus when it is initialized, containing all configurable values
15 | public struct TKBusConfig {
16 | internal var clientName: String
17 | internal var model: String
18 | internal var manufacturer: String
19 | internal var inputConnectionName: String = "TriggerKit"
20 | internal var decimalPlaces: Int
21 |
22 | /// Configuration for the TriggerKit Bus
23 | /// - Parameters:
24 | /// - clientName: Name identifying this instance, used via MIDIKit as a Core MIDI client ID
25 | /// - model: The name of your client application, used by MIDIKit
26 | /// - manufacturer: The name of your company used by MIDIKit
27 | /// created by the manager.
28 | /// - decimalPlaces: the number of decimal places MIDI CC values are rounded to
29 | public init(clientName: String,
30 | model: String,
31 | manufacturer: String,
32 | decimalPlaces: Int = 2,
33 | throttleRate: Double = 1 / 120) {
34 | self.clientName = clientName
35 | self.model = model
36 | self.manufacturer = manufacturer
37 | self.decimalPlaces = decimalPlaces
38 | }
39 | }
40 |
41 | /// Represents the a unique mapping of an app action to an event
42 | ///
43 | /// Mappings are registered with TKBus in order to associate events, and app actions, to blocks of code to be triggered.
44 | public struct TKMapping: Hashable, Codable, Identifiable where V: TKAppActionConstraints {
45 | /// The unique id of the mapping
46 | public var id: UUID
47 | /// Your application's enum representing an action that the app has
48 | public var appAction: V
49 | /// The TriggerKit event that triggers the appAction and associated block of code
50 | public var event: TKEvent?
51 |
52 | /// Represents the a unique mapping of an app action to an event
53 | /// - Parameters:
54 | /// - id: the unique ID of the mapping. This can be supplied by the client app if decoding existing mappings, or created by the initializer itself.
55 | /// - appAction: Your application's enum representing an action that the app has
56 | /// - event: The TriggerKit event that triggers the appAction and associated block of code
57 | public init(id: UUID = UUID(), appAction: V, event: TKEvent?) {
58 | self.id = id
59 | self.appAction = appAction
60 | self.event = event
61 | }
62 | }
63 |
64 | /// Core TriggerKit Bus object
65 | ///
66 | /// This is the core TriggerKit bus, that your application will use to hold mappings in memory and trigger them according to the events supplied.
67 | public class TKBus: ObservableObject where V: TKAppActionConstraints {
68 | // MARK: - Published public properties
69 |
70 | /// The latest event received
71 | @Published public var event: TKEvent?
72 |
73 | /// The current mappings associated with events
74 | @Published public var mappings: [TKMapping] = []
75 |
76 | // MARK: - Published private properties
77 |
78 | /// The latest MIDI Note event
79 | @Published private var latestNoteEvent: MIDIEvent?
80 |
81 | /// The latest MIDI CC event
82 | @Published private var latestCCEvent: MIDIEvent?
83 |
84 | // MARK: - Public properties
85 | public var midiManager: MIDIManager
86 |
87 | // MARK: - Private properties
88 |
89 | /// The configuration supplied by the application
90 | private var config: TKBusConfig
91 |
92 | /// A look-up for the callbacks to call when events are triggered, linked by the UUID
93 | private var callbacks: [UUID: TKPayloadCallback] = [:]
94 |
95 | /// A single callback for each event, to enable event learning in the application
96 | private var eventCallback: TKEventCallback?
97 |
98 | /// The store for TriggerKit's Combine bindings
99 | private var cancellables = Set()
100 |
101 | /// A value for the input connection, used with MIDIKit
102 | private var inputConnection: MIDIInputConnection? {
103 | midiManager.managedInputConnections[config.inputConnectionName]
104 | }
105 |
106 | // MARK: - Initialization
107 |
108 | /// Initialize the TKBus object
109 | /// - Parameter config: A struct containing all configurable values
110 | public init(config: TKBusConfig) {
111 | self.config = config
112 |
113 | midiManager = MIDIManager(
114 | clientName: config.clientName,
115 | model: config.model,
116 | manufacturer: config.manufacturer
117 | )
118 |
119 | self.setBindings()
120 | }
121 |
122 | /// Bindings triggered when events are received from MIDI
123 | private func setBindings() {
124 | self.$latestCCEvent
125 | .sink { event in
126 | guard let event else { return }
127 | self.handleMidiEvent(event)
128 | }
129 | .store(in: &cancellables)
130 |
131 | self.$latestNoteEvent
132 | .sink { event in
133 | guard let event else { return }
134 | self.handleMidiEvent(event)
135 | }
136 | .store(in: &cancellables)
137 | }
138 |
139 | /// Called when you are ready for the TKBus to set up connections and listen for events
140 | public func start() throws {
141 | try midiManager.start()
142 |
143 | try midiManager.addInputConnection(
144 | toOutputs: [], // no need to specify if we're using .allEndpoints
145 | tag: config.inputConnectionName,
146 | mode: .allEndpoints, // auto-connect to all outputs that may appear
147 | filter: .owned(), // don't allow self-created virtual endpoints
148 | receiver: .events({ [weak self] events in
149 | self?.processMidiEvents(events)
150 | })
151 | )
152 | }
153 |
154 | /// Private handler method for event arrays received from the MIDI manager
155 | private func processMidiEvents(_ events: [MIDIEvent]) {
156 | events.forEach({ event in
157 | handleMidiEvent(event)
158 | })
159 | }
160 |
161 | /// Handles a single event
162 | /// - Parameter midiEvent: handles each single midi event, and ensure that callbacks are called on the main thread
163 | internal func handleMidiEvent(_ midiEvent: MIDIEvent) {
164 | guard
165 | let event = TKEvent.createEventFrom(midiEvent: midiEvent),
166 | let payload = createPayload(midiEvent)
167 | else {
168 | return
169 | }
170 |
171 | DispatchQueue.main.async { [weak self] in
172 | self?.eventCallback?(event)
173 | }
174 |
175 | let mappings = self.mappings.filter({ $0.event == event })
176 |
177 | mappings.forEach { mapping in
178 | guard let callback = self.callbacks[mapping.id] else { return }
179 |
180 | DispatchQueue.main.async {
181 | callback(payload)
182 | }
183 | }
184 | }
185 |
186 | /// Creates a PayLoad from a midi event
187 | ///
188 | /// - Parameter event: the midi event to create a payload from
189 | /// - Returns: a standard TK payload struct
190 | internal func createPayload(_ event: MIDIEvent) -> TKPayLoad? {
191 | switch event {
192 | case .noteOn(let noteOn):
193 | return createPayload(value: Double(noteOn.note.number), value2: noteOn.velocity.unitIntervalValue)
194 | case .cc(let ccEvent):
195 | return createPayload(value: ccEvent.value.unitIntervalValue)
196 | default:
197 | break
198 | }
199 |
200 | return nil
201 | }
202 |
203 | }
204 |
205 | // MARK: - TKEvent Mappings
206 | extension TKBus {
207 |
208 | /// Add Mapping
209 | ///
210 | /// This updates an existing mapping if the UUID matches one held, OR adds a new one at the end of the mappings array if not. Executes changes to the mappings property on the main thread.
211 | /// - Parameters:
212 | /// - newMapping: the mapping to be created
213 | /// - callback: the callback to call when the mapping's event is received.
214 | public func addMapping(_ newMapping: TKMapping, callback: TKPayloadCallback?) {
215 | DispatchQueue.main.async { [weak self] in
216 | if let index = self?.mappings.firstIndex(where: { mapping in
217 | mapping.id == newMapping.id
218 | }) {
219 | self?.mappings[index] = newMapping
220 | } else {
221 | self?.mappings.append(newMapping)
222 | }
223 |
224 | self?.callbacks[newMapping.id] = nil
225 |
226 | self?.callbacks[newMapping.id] = callback
227 | }
228 | }
229 |
230 | public func removeMapping(_ mapping: TKMapping) {
231 | DispatchQueue.main.async { [weak self] in
232 | self?.mappings.removeAll { item in
233 | item.id == mapping.id
234 | }
235 |
236 | self?.callbacks[mapping.id] = nil
237 | }
238 | }
239 |
240 | }
241 |
242 | // MARK: - Event Mappings
243 | extension TKBus {
244 | /// Updates the event callback that is executed when a new event is received. This supports event learning in the application.
245 | /// - Parameter callback: The callback to be called
246 | public func setEventCallback(_ callback: TKEventCallback?) {
247 | eventCallback = callback
248 | }
249 |
250 | /// Removes the current event bacllback that is executed when a new event is received.
251 | public func removeEventCallback() {
252 | eventCallback = nil
253 | }
254 | }
255 |
256 | // MARK: - Convenience functions
257 | extension TKBus {
258 |
259 | /// Creates a payload based on a the values provided. Used internally to have one spot where this happens
260 | ///
261 | /// - Parameters:
262 | /// - value: the main value of the payload
263 | /// - value2: the secondary value of the paylaod
264 | /// - message: some events pass messages back. This is future proofing for support for things like OSC where events can have some extra meta data supplied.
265 | /// - Returns: a TK Payload struct
266 | internal func createPayload(value: Double, value2: Double? = nil, message: String? = nil) -> TKPayLoad {
267 | let payload = TKPayLoad(value: roundDouble(value) ?? 0,
268 | value2: roundDouble(value2),
269 | message: message)
270 | return payload
271 | }
272 |
273 | internal func roundDouble(_ value: Double?) -> Double? {
274 | guard let value else { return nil }
275 |
276 | let multiplier = pow(Double(10), Double(config.decimalPlaces))
277 |
278 | let roundedValue = Double(round(multiplier * value))
279 | return roundedValue / multiplier
280 | }
281 |
282 | internal func logEvent(_ event: TKEvent?) {
283 | DispatchQueue.main.async { [weak self] in
284 | self?.eventCallback?(event)
285 | }
286 | }
287 |
288 | }
289 |
--------------------------------------------------------------------------------
/Sources/TriggerKit/Views/TKBluetoothMIDIView.swift:
--------------------------------------------------------------------------------
1 | // Shamelessly 'acquired' from MIDIKit, with grateful thanks to Steffan Andrews' work.
2 | // BluetoothMIDIView.swift
3 | // MIDIKit • https://github.com/orchetect/MIDIKit
4 | // © 2021-2023 Steffan Andrews • Licensed under MIT License
5 | //
6 |
7 | #if os(iOS)
8 |
9 | import UIKit
10 | import SwiftUI
11 | import CoreAudioKit
12 |
13 | /// A SwiftUI view that wraps the CABTMIDICentralViewController
14 | public struct TKBluetoothMIDIView: UIViewControllerRepresentable {
15 | public init() {
16 | }
17 |
18 | public func makeUIViewController(context: Context) -> TKBTMIDICentralViewController {
19 | TKBTMIDICentralViewController()
20 | }
21 |
22 | public func updateUIViewController(
23 | _ uiViewController: TKBTMIDICentralViewController,
24 | context: Context
25 | ) { }
26 |
27 | public typealias UIViewControllerType = TKBTMIDICentralViewController
28 | }
29 |
30 | /// A UIKit view controller that wraps the CABTMIDICentralViewController
31 | public class TKBTMIDICentralViewController: CABTMIDICentralViewController {
32 | public var uiViewController: UIViewController?
33 |
34 | override public func viewDidLayoutSubviews() {
35 | super.viewDidLayoutSubviews()
36 |
37 | navigationItem.rightBarButtonItem = UIBarButtonItem(
38 | barButtonSystemItem: .done,
39 | target: self,
40 | action: #selector(doneAction)
41 | )
42 | }
43 |
44 | @objc
45 | public func doneAction() {
46 | uiViewController?.dismiss(animated: true, completion: nil)
47 | }
48 | }
49 |
50 | #endif
51 |
--------------------------------------------------------------------------------
/Tests/TriggerKitTests/ConfigTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ConfigTests.swift
3 | //
4 | //
5 | // Created by dave on 14/05/23.
6 | //
7 |
8 | import XCTest
9 | @testable import TriggerKit
10 |
11 | final class ConfigTests: XCTestCase {
12 |
13 | enum TestAction: TKAppActionConstraints {
14 | case testAction1
15 | case testAction2
16 | case testAction3
17 | }
18 |
19 | func testStandardRoundingUp() {
20 | // Given
21 | let config = TKBusConfig(clientName: "TriggerKit", model: "TriggerKit", manufacturer: "Lightbeam Apps")
22 | let bus = TKBus(config: config)
23 | let mapping = TKMapping(appAction: TestAction.testAction1, event: TKEvent.midiCC(trigger: .init(cc: 1)))
24 |
25 | let expectation = expectation(description: "testNonStandardRoundingUp")
26 |
27 | bus.addMapping(mapping) { payload in
28 | if payload.value == 0.12 {
29 | expectation.fulfill()
30 | }
31 | }
32 |
33 | waitForMainThread()
34 |
35 | // When
36 | bus.handleMidiEvent(.cc(1, value:.unitInterval(0.115), channel: 0))
37 |
38 | // Then
39 | defaultWaitForExpectations()
40 | }
41 |
42 | func testNonStandardRoundingUp() {
43 | // Given
44 | let config = TKBusConfig(clientName: "TriggerKit", model: "TriggerKit", manufacturer: "Lightbeam Apps", decimalPlaces: 3)
45 | let bus = TKBus(config: config)
46 | let mapping = TKMapping(appAction: TestAction.testAction1, event: TKEvent.midiCC(trigger: .init(cc: 1)))
47 |
48 | let expectation = expectation(description: "testNonStandardRoundingUp")
49 |
50 | bus.addMapping(mapping) { payload in
51 | if payload.value == 0.112 {
52 | expectation.fulfill()
53 | }
54 | }
55 |
56 | waitForMainThread()
57 |
58 | // When
59 | bus.handleMidiEvent(.cc(1, value:.unitInterval(0.1115), channel: 0))
60 |
61 | // Then
62 | defaultWaitForExpectations()
63 | }
64 |
65 | func testStandardRoundingDown() {
66 | // Given
67 | let config = TKBusConfig(clientName: "TriggerKit", model: "TriggerKit", manufacturer: "Lightbeam Apps")
68 | let bus = TKBus(config: config)
69 | let mapping = TKMapping(appAction: TestAction.testAction1, event: TKEvent.midiCC(trigger: .init(cc: 1)))
70 |
71 | let expectation = expectation(description: "testNonStandardRoundingUp")
72 |
73 | bus.addMapping(mapping) { payload in
74 | if payload.value == 0.11 {
75 | expectation.fulfill()
76 | }
77 | }
78 |
79 | waitForMainThread()
80 |
81 | // When
82 | bus.handleMidiEvent(.cc(1, value:.unitInterval(0.114), channel: 0))
83 |
84 | // Then
85 | defaultWaitForExpectations()
86 | }
87 |
88 | func testNonStandardRoundingDown() {
89 | // Given
90 | let config = TKBusConfig(clientName: "TriggerKit", model: "TriggerKit", manufacturer: "Lightbeam Apps", decimalPlaces: 3)
91 | let bus = TKBus(config: config)
92 | let mapping = TKMapping(appAction: TestAction.testAction1, event: TKEvent.midiCC(trigger: .init(cc: 1)))
93 |
94 | let expectation = expectation(description: "testNonStandardRoundingUp")
95 |
96 | bus.addMapping(mapping) { payload in
97 | if payload.value == 0.111 {
98 | expectation.fulfill()
99 | }
100 | }
101 |
102 | waitForMainThread()
103 |
104 | // When
105 | bus.handleMidiEvent(.cc(1, value:.unitInterval(0.1114), channel: 0))
106 |
107 | // Then
108 | defaultWaitForExpectations()
109 | }
110 |
111 | }
112 |
--------------------------------------------------------------------------------
/Tests/TriggerKitTests/MappingTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import TriggerKit
3 |
4 | final class MappingTests: XCTestCase {
5 |
6 | private func createConfig() -> TKBusConfig {
7 | TKBusConfig(clientName: "TriggerKit", model: "TriggerKit", manufacturer: "Lightbeam Apps")
8 | }
9 |
10 | enum TestAction: TKAppActionConstraints {
11 | case testAction1
12 | case testAction2
13 | case testAction3
14 | }
15 |
16 | var bus: TKBus!
17 |
18 | override func setUp() async throws {
19 | try await super.setUp()
20 |
21 | bus = TKBus(config: self.createConfig())
22 | }
23 |
24 | func testAddingAMapping() {
25 | // Given
26 | let expectation = self.expectation(description: "testAddingAMapping - called")
27 | let event = TKEvent.midiCC(trigger: .init(cc: 1))
28 | let mapping = TKMapping(appAction: TestAction.testAction1, event: event)
29 |
30 | bus.addMapping(mapping) { payload in
31 | if payload.value == 0.5 {
32 | expectation.fulfill()
33 | }
34 | }
35 |
36 | // We have to wait for the main thread for our mapping to be active
37 | waitForMainThread()
38 |
39 | // When
40 | bus.handleMidiEvent(.cc(1, value: .unitInterval(0.5), channel: 0))
41 |
42 | // Then
43 | self.defaultWaitForExpectations()
44 | }
45 |
46 | func testRemovingAMapping() {
47 | // Given
48 | let event = TKEvent.midiCC(trigger: .init(cc: 1))
49 | let mapping = TKMapping(appAction: TestAction.testAction1, event: event)
50 |
51 | bus.addMapping(mapping) { _ in
52 | // do nothing
53 | }
54 |
55 | // We have to wait for the main thread for our mapping to be active
56 | waitForMainThread()
57 |
58 | XCTAssertEqual(bus.mappings.first, mapping)
59 | XCTAssertEqual(bus.mappings.count, 1)
60 |
61 | // When
62 | bus.removeMapping(mapping)
63 |
64 | // We have to wait for the main thread for our mapping to be removed
65 | waitForMainThread()
66 |
67 | // Then
68 | XCTAssertNil(bus.mappings.first)
69 | XCTAssertTrue(bus.mappings.isEmpty)
70 | }
71 |
72 | func testUpdatingAMapping() {
73 | // Given
74 | let event1 = TKEvent.midiCC(trigger: .init(cc: 1))
75 | let mapping1 = TKMapping(appAction: TestAction.testAction1, event: event1)
76 | let event2 = TKEvent.midiCC(trigger: .init(cc: 2))
77 | let mapping2 = TKMapping(appAction: TestAction.testAction2, event: event2)
78 |
79 | bus.addMapping(mapping1, callback: { _ in })
80 | bus.addMapping(mapping2, callback: { _ in })
81 |
82 | // We have to wait for the main thread for our mapping to be active
83 | waitForMainThread()
84 |
85 | XCTAssertEqual(bus.mappings[0], mapping1)
86 | XCTAssertEqual(bus.mappings[1], mapping2)
87 | XCTAssertEqual(bus.mappings.count, 2)
88 | XCTAssertEqual(bus.mappings[0].event, event1)
89 | XCTAssertEqual(bus.mappings[1].event, event2)
90 |
91 | // When
92 | let updatedEvent1 = TKEvent.midiNote(trigger: .init(note: 1, noteString: "1"))
93 | var mapping1_Updated = mapping1
94 | mapping1_Updated.event = updatedEvent1
95 |
96 | bus.addMapping(mapping1_Updated, callback: { _ in })
97 |
98 | // We have to wait for the main thread for our mapping to be removed
99 | waitForMainThread()
100 |
101 | // Then
102 | XCTAssertEqual(bus.mappings[0], mapping1_Updated)
103 | XCTAssertEqual(bus.mappings[1], mapping2)
104 | XCTAssertEqual(bus.mappings.count, 2)
105 | XCTAssertEqual(bus.mappings[0].event, updatedEvent1)
106 | XCTAssertEqual(bus.mappings[1].event, event2)
107 | }
108 |
109 | func testAddingANewMapping() {
110 | // Given
111 | let event1 = TKEvent.midiCC(trigger: .init(cc: 1))
112 | let mapping1 = TKMapping(appAction: TestAction.testAction1, event: event1)
113 | let event2 = TKEvent.midiCC(trigger: .init(cc: 2))
114 | let mapping2 = TKMapping(appAction: TestAction.testAction2, event: event2)
115 |
116 | bus.addMapping(mapping1, callback: { _ in })
117 | bus.addMapping(mapping2, callback: { _ in })
118 |
119 | // We have to wait for the main thread for our mapping to be active
120 | waitForMainThread()
121 |
122 | XCTAssertEqual(bus.mappings[0], mapping1)
123 | XCTAssertEqual(bus.mappings[1], mapping2)
124 | XCTAssertEqual(bus.mappings.count, 2)
125 | XCTAssertEqual(bus.mappings[0].event, event1)
126 | XCTAssertEqual(bus.mappings[1].event, event2)
127 |
128 | let newEvent = TKEvent.midiNote(trigger: .init(note: 1, noteString: "1"))
129 | let newMapping = TKMapping(appAction: TestAction.testAction3, event: newEvent)
130 |
131 | bus.addMapping(newMapping, callback: { _ in })
132 |
133 | // We have to wait for the main thread for our mapping to be removed
134 | waitForMainThread()
135 |
136 | // Then
137 | XCTAssertEqual(bus.mappings[0], mapping1)
138 | XCTAssertEqual(bus.mappings[1], mapping2)
139 | XCTAssertEqual(bus.mappings[2], newMapping)
140 | XCTAssertEqual(bus.mappings.count, 3)
141 | XCTAssertEqual(bus.mappings[0].event, event1)
142 | XCTAssertEqual(bus.mappings[1].event, event2)
143 | XCTAssertEqual(bus.mappings[2].event, newEvent)
144 | }
145 |
146 | func testDuplicatingAMapping() {
147 | // Given
148 | let event1 = TKEvent.midiCC(trigger: .init(cc: 1))
149 | let mapping1 = TKMapping(appAction: TestAction.testAction1, event: event1)
150 | let mappingDupe = TKMapping(appAction: TestAction.testAction1, event: event1)
151 |
152 | XCTAssertTrue(bus.mappings.isEmpty)
153 |
154 | // When
155 | bus.addMapping(mapping1, callback: { _ in })
156 | bus.addMapping(mappingDupe, callback: { _ in })
157 |
158 | // We have to wait for the main thread for our mappings to be active
159 | waitForMainThread()
160 |
161 | // Then we should have both in the bus's mapping
162 | XCTAssertEqual(bus.mappings[0], mapping1)
163 | XCTAssertEqual(bus.mappings[1], mappingDupe)
164 | XCTAssertEqual(bus.mappings.count, 2)
165 | XCTAssertEqual(bus.mappings[0].event, event1)
166 | XCTAssertEqual(bus.mappings[1].event, event1)
167 | }
168 |
169 | func testDuplicatingAMappingTriggersBothCallbacks() {
170 | // Given
171 | let event1 = TKEvent.midiCC(trigger: .init(cc: 1))
172 | let mapping1 = TKMapping(appAction: TestAction.testAction1, event: event1)
173 | let mappingDupe = TKMapping(appAction: TestAction.testAction1, event: event1)
174 |
175 | XCTAssertTrue(bus.mappings.isEmpty)
176 |
177 | let expectation1 = expectation(description: "expectation1")
178 | let expectation2 = expectation(description: "expectation2")
179 |
180 | bus.addMapping(mapping1, callback: { _ in
181 | expectation1.fulfill()
182 | })
183 |
184 | bus.addMapping(mappingDupe, callback: { _ in
185 | expectation2.fulfill()
186 | })
187 |
188 | // We have to wait for the main thread for our mappings to be active
189 | waitForMainThread()
190 |
191 | // When
192 | bus.handleMidiEvent(.cc(1, value: .unitInterval(0.5), channel: 0))
193 |
194 | waitForMainThread()
195 |
196 | // Then
197 | defaultWaitForExpectations()
198 | }
199 | }
200 |
--------------------------------------------------------------------------------
/Tests/TriggerKitTests/TestConfig.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TestConfig.swift
3 | //
4 | //
5 | // Created by dave on 14/05/23.
6 | //
7 |
8 | import XCTest
9 |
10 | enum TestConfig {
11 | static var defaultMaxWaitTime: TimeInterval = 1.0
12 | }
13 |
14 | extension XCTestCase {
15 | func defaultWaitForExpectations() {
16 | self.waitForExpectations(timeout: TestConfig.defaultMaxWaitTime)
17 | }
18 | }
19 |
20 | extension XCTestCase {
21 | func waitForMainThread() {
22 | let expectation = self.expectation(description: "TriggerKit - waiting for main thread")
23 | DispatchQueue.main.async {
24 | expectation.fulfill()
25 | }
26 |
27 | self.wait(for: [expectation], timeout: TestConfig.defaultMaxWaitTime)
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/media/logo.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------