├── .github ├── CODEOWNERS └── workflows │ └── build.yml ├── .gitignore ├── Package.swift ├── Package@swift-5.5.swift ├── Sources └── CombineMIDI │ ├── MIDIPublisher.swift │ ├── MIDI.swift │ ├── MIDIStream.swift │ ├── MIDIClient.swift │ ├── MIDISubscription.swift │ └── MIDIMessage.swift ├── Tests └── CombineMIDITests │ └── MemoryTests.swift ├── LICENSE ├── Proposal.md └── README.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @mkj-is 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | .swiftpm 7 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: macos-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Build 17 | run: swift build 18 | - name: Run tests 19 | run: swift test 20 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "CombineMIDI", 7 | platforms: [.iOS(.v13), .macOS(.v10_15)], 8 | products: [ 9 | .library(name: "CombineMIDI", targets: ["CombineMIDI"]) 10 | ], 11 | targets: [ 12 | .target(name: "CombineMIDI"), 13 | .testTarget(name: "CombineMIDITests", dependencies: ["CombineMIDI"]) 14 | ] 15 | ) 16 | -------------------------------------------------------------------------------- /Package@swift-5.5.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "CombineMIDI", 7 | platforms: [.iOS(.v13), .macOS(.v10_15)], 8 | products: [ 9 | .library(name: "CombineMIDI", targets: ["CombineMIDI"]) 10 | ], 11 | targets: [ 12 | .target(name: "CombineMIDI"), 13 | .testTarget(name: "CombineMIDITests", dependencies: ["CombineMIDI"]) 14 | ] 15 | ) 16 | -------------------------------------------------------------------------------- /Sources/CombineMIDI/MIDIPublisher.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | /// A publisher that emits MIDI messages from a MIDI client when received. 5 | public struct MIDIPublisher: Publisher { 6 | public typealias Output = MIDIMessage 7 | public typealias Failure = Never 8 | 9 | private let client: MIDIClient 10 | 11 | init(client: MIDIClient) { 12 | self.client = client 13 | } 14 | 15 | public func receive(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input { 16 | let subscription = MIDISubscription(client: client, subscriber: subscriber) 17 | subscriber.receive(subscription: subscription) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/CombineMIDI/MIDI.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | 4 | /// MIDI observable object updating every time new MIDI message 5 | /// is received. 6 | public final class MIDI: ObservableObject { 7 | @Published public private(set) var message: MIDIMessage = .allNotesOff 8 | 9 | private var cancellable: Cancellable? 10 | 11 | /// Initializes a new MIDI observable object for a supplied client. 12 | /// - Parameter client: Client which will be used for listening 13 | /// to MIDI messages. By default a new client with default name 14 | /// will be created. 15 | public init(client: MIDIClient = MIDIClient()) { 16 | self.cancellable = client 17 | .publisher() 18 | .receive(on: RunLoop.main) 19 | .sink { [weak self] message in 20 | self?.message = message 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Tests/CombineMIDITests/MemoryTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Combine 3 | @testable import CombineMIDI 4 | 5 | final class MemoryTests: XCTestCase { 6 | func testObservableObjectRelease() { 7 | var midi: MIDI? = MIDI() 8 | weak var weakMidi = midi 9 | midi = nil 10 | XCTAssertNil(weakMidi) 11 | } 12 | 13 | func testClientRelease() { 14 | var client: MIDIClient? = MIDIClient() 15 | weak var weakClient = client 16 | 17 | var publisher: MIDIPublisher? = client?.publisher() 18 | XCTAssertNotNil(publisher) 19 | 20 | var cancellable: Cancellable? = publisher?.sink { _ in } 21 | 22 | client = nil 23 | publisher = nil 24 | XCTAssertNotNil(cancellable) 25 | XCTAssertNotNil(weakClient) 26 | 27 | cancellable = nil 28 | XCTAssertNil(weakClient) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2021 Matěj Kašpar Jirásek 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/CombineMIDI/MIDIStream.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreMIDI 3 | 4 | @available(macOS 10.15, iOS 13.0, *) 5 | struct MIDIStream { 6 | private var port = MIDIPortRef() 7 | 8 | init(client: MIDIClient, receive: @escaping (MIDIMessage) -> Void) { 9 | // Currently read partial message 10 | var currentMessage = MIDIMessage() 11 | 12 | MIDIInputPortCreateWithBlock(client.client, client.generatePortName() as CFString, &port) { pointer, _ in 13 | pointer 14 | .unsafeSequence() 15 | .flatMap { $0.sequence() } 16 | .reduce(into: [MIDIMessage]()) { messages, byte in 17 | currentMessage.bytes.append(byte) 18 | if currentMessage.isComplete { 19 | messages.append(currentMessage) 20 | currentMessage = MIDIMessage() 21 | } 22 | } 23 | .forEach(receive) 24 | } 25 | for i in 0...MIDIGetNumberOfSources() { 26 | MIDIPortConnectSource(port, MIDIGetSource(i), nil) 27 | } 28 | } 29 | 30 | func terminate() { 31 | MIDIPortDispose(port) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Proposal.md: -------------------------------------------------------------------------------- 1 | # Combine: Connect MIDI signals to SwiftUI 2 | 3 | Over the years I have been exploring generative design and trying to find 4 | the balance between art and technology. Core MIDI framework is a great way 5 | of introducing the audience to this fascinating world. Using exclusively 6 | Apple-provided APIs, we can create something inspiring and learn something new. 7 | 8 | Reactive programming concepts are hard to grasp for a lot of developers. 9 | What if there is a better way to experiment and learn reactive programming 10 | concepts in real-time? Let's take MIDI input, which is a great example, 11 | and connect it using Combine to SwiftUI. 12 | 13 | ## Overview 14 | 15 | - What is MIDI? 16 | - Brief introduction to Core MIDI framework. 17 | - Connecting Combine to Core MIDI. 18 | - Presenting real-time values in SwiftUI. 19 | - Live demo in macOS SwiftUI app with MIDI controllers. 20 | 21 | ## Key goals of the presentation 22 | 23 | - Introduce Core MIDI as a point of interest for developers. 24 | - Present how barely known framework can be leveraged to learn something new. 25 | - Inspire developers how simple connecting to hardware devices can be. 26 | 27 | ## Audience 28 | 29 | - People interested in audio-visual programming. 30 | - Developers with interest in multi-platform development (macOS and iOS). 31 | - Developers interested in communication with hardware devices. 32 | - People eager to learn new Apple technologies and reactive programming. 33 | -------------------------------------------------------------------------------- /Sources/CombineMIDI/MIDIClient.swift: -------------------------------------------------------------------------------- 1 | import CoreMIDI 2 | 3 | /// Object holding a reference to a low-level CoreMIDI client 4 | /// when it is in memory. 5 | public final class MIDIClient { 6 | private(set) var client = MIDIClientRef() 7 | private let name: String 8 | 9 | /// Initializes the client with a supplied name. 10 | /// - Parameter name: Name of the client, "Combine Client" by default. 11 | public init(name: String = "Combine Client") { 12 | self.name = name 13 | MIDIClientCreate(name as CFString, nil, nil, &client) 14 | } 15 | 16 | deinit { 17 | MIDIClientDispose(client) 18 | } 19 | 20 | /// Returns new MIDI message publisher for this client. 21 | public func publisher() -> MIDIPublisher { 22 | MIDIPublisher(client: self) 23 | } 24 | 25 | #if swift(>=5.5.2) 26 | /// Creates a new asynchronous stream by automatically creating new 27 | /// MIDI port and connecting to all available sources. 28 | @available(macOS 10.15, iOS 13.0, *) 29 | public var stream: AsyncStream { 30 | AsyncStream { continuation in 31 | let stream = MIDIStream(client: self) { message in 32 | continuation.yield(message) 33 | } 34 | continuation.onTermination = { @Sendable _ in 35 | stream.terminate() 36 | } 37 | } 38 | } 39 | #endif 40 | 41 | func generatePortName() -> String { 42 | "\(name)-\(UUID().uuidString)" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/CombineMIDI/MIDISubscription.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import CoreMIDI 3 | 4 | final class MIDISubscription: Subscription where S.Input == MIDIMessage, S.Failure == Never { 5 | let combineIdentifier = CombineIdentifier() 6 | 7 | private var subscriber: S? 8 | private let client: MIDIClient 9 | 10 | private var port = MIDIPortRef() 11 | private var demand: Subscribers.Demand = .none 12 | private var portCreated = false 13 | 14 | init(client: MIDIClient, subscriber: S) { 15 | self.client = client 16 | self.subscriber = subscriber 17 | } 18 | 19 | func request(_ newDemand: Subscribers.Demand) { 20 | guard newDemand > .none else { 21 | return 22 | } 23 | demand += newDemand 24 | guard portCreated else { 25 | createPort() 26 | return 27 | } 28 | } 29 | 30 | func cancel() { 31 | disposePort() 32 | subscriber = nil 33 | } 34 | 35 | private func createPort() { 36 | // Currently read partial message 37 | var currentMessage = MIDIMessage() 38 | 39 | MIDIInputPortCreateWithBlock(client.client, client.generatePortName() as CFString, &port) { pointer, _ in 40 | pointer 41 | .unsafeSequence() 42 | .flatMap { $0.sequence() } 43 | .reduce(into: [MIDIMessage]()) { messages, byte in 44 | currentMessage.bytes.append(byte) 45 | if currentMessage.isComplete { 46 | messages.append(currentMessage) 47 | currentMessage = MIDIMessage() 48 | } 49 | } 50 | .forEach { [weak self] message in 51 | guard let self = self, let subscriber = self.subscriber else { return } 52 | guard self.demand > .none else { 53 | self.disposePort() 54 | return 55 | } 56 | 57 | self.demand -= .max(1) 58 | _ = subscriber.receive(message) 59 | } 60 | } 61 | for i in 0...MIDIGetNumberOfSources() { 62 | MIDIPortConnectSource(port, MIDIGetSource(i), nil) 63 | } 64 | portCreated = true 65 | } 66 | 67 | private func disposePort() { 68 | MIDIPortDispose(port) 69 | portCreated = false 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CombineMIDI 2 | 3 | ![Build](https://github.com/mkj-is/CombineMIDI/workflows/Build/badge.svg) 4 | 5 | Swift package made for easy connection of MIDI controllers to SwiftUI 6 | (or UIKit) using Combine and async-await. 7 | 8 | This package was created as a part of [UIKonf 2020](https://uikonf.com) 9 | talk **Combine: Connect MIDI signals to SwiftUI**. 10 | It's main goal is to read messages from all MIDI sources 11 | and to be able to present these values in the user interface. 12 | 13 | For more guidance, demos and history see materials for the talk: 14 | 15 | - [Video](https://youtu.be/2jTtqoYwQF0) 16 | - [Slides](https://speakerdeck.com/mkj/combine-connect-midi-signals-to-swiftui) 17 | - [Proposal](Proposal.md) 18 | 19 | ## Installation 20 | 21 | Add this package to your Xcode project or add following line 22 | to your `Package.swift` file: 23 | 24 | ```swift 25 | .package(url: "https://github.com/mkj-is/CombineMIDI.git", from: "0.3.0") 26 | ``` 27 | 28 | ## Features 29 | 30 | - Supports macOS 10.15+ and iOS 13.0+. 31 | - Type-safe wrapper for MIDI messages (events). 32 | - Wrapper for the C-style MIDI client in CoreMIDI. 33 | - Combine Publisher for listening to MIDI messages. 34 | - MIDI AsyncStream for processing MIDI messages. 35 | 36 | ## Usage 37 | 38 | First you need to create a MIDI client, it should be sufficient to create 39 | only one client per app 40 | (you can optionally pass name of the client as the initializer parameter): 41 | 42 | ```swift 43 | let client = MIDIClient(name: "My app MIDI client") 44 | ``` 45 | 46 | ### Async-await 47 | 48 | The first thing you probably want to do is to filter the messages 49 | which are relevant to you and get the values. 50 | 51 | ```swift 52 | let stream = client.stream 53 | .filter { $0.status == .controlChange } 54 | .compactMap(\.data2) 55 | ``` 56 | 57 | The you can process messages in a simple for-loop. 58 | *The loop will be running indefinitely until the task 59 | enclosing it will be cancelled.* 60 | 61 | ``` 62 | for await message in stream { 63 | ... 64 | } 65 | ``` 66 | 67 | ### Combine 68 | 69 | When using Combine, you create a publisher and it automatically connects 70 | to all sources and listens to all messages on subscribtion. 71 | 72 | *Do not forget to receive those events on the main thread when subscribing 73 | on the user interface. To prevent dispatching too soon the publisher is 74 | emitting on the thread used by CoreMIDI, so you can quickly filter all 75 | the messages.* 76 | 77 | ```swift 78 | cancellable = client 79 | .publisher() 80 | .filter { $0.status == .controlChange } 81 | .compactMap(\.data2) 82 | .receive(on: RunLoop.main) 83 | .sink { value in 84 | ... 85 | } 86 | ``` 87 | 88 | ## Next steps 89 | 90 | This library is small on purpose and there is a large potential for improvement: 91 | 92 | - [ ] New MIDI controllers are not detected when the subscription was already made. 93 | - [ ] All MIDI sources are connected during the first subscription, 94 | there is currently no way to select a particular device. 95 | - [ ] Sending messages back to other destinations using the client is not possible. 96 | 97 | ## Contributing 98 | 99 | All contributions are welcome. 100 | 101 | Project was created by [Matěj Kašpar Jirásek](https://twitter.com/mkj_is). 102 | 103 | Project is licensed under [MIT license](LICENSE). 104 | -------------------------------------------------------------------------------- /Sources/CombineMIDI/MIDIMessage.swift: -------------------------------------------------------------------------------- 1 | /// Typed MIDI message interpretation. 2 | public struct MIDIMessage { 3 | /// Kind of the message. 4 | public enum Status: UInt8 { 5 | /// Note Off event. 6 | /// This message is sent when a note is released (ended). 7 | case noteOff = 0x80 8 | 9 | /// Note On event. 10 | /// This message is sent when a note is depressed (start). 11 | case noteOn = 0x90 12 | 13 | /// Polyphonic Key Pressure (Aftertouch). 14 | /// This message is most often sent by pressing down on 15 | /// the key after it "bottoms out". 16 | case aftertouch = 0xA0 17 | 18 | /// Control Change. This message is sent when a controller 19 | /// value changes. Controllers include devices such as 20 | /// pedals and levers. 21 | case controlChange = 0xB0 22 | 23 | /// Program Change. This message sent when the patch 24 | /// number changes. 25 | case programChange = 0xC0 26 | 27 | /// Channel Pressure (After-touch). 28 | /// This message is most often sent by pressing down on 29 | /// the key after it "bottoms out". This message is 30 | /// different from polyphonic after-touch. Use this 31 | /// message to send the single greatest pressure value 32 | /// (of all the current depressed keys). 33 | case channelPressure = 0xD0 34 | 35 | /// Pitch Bend Change. This message is sent to indicate 36 | /// a change in the pitch bender (wheel or lever, typically). 37 | /// The pitch bender is measured by a fourteen bit value. 38 | case pitchBendChange = 0xE0 39 | 40 | /// Tune request. Tells analog synthesizers to tune. 41 | case tuneRequest = 0xF6 42 | 43 | // System Real-Time Messages 44 | 45 | /// Timing Clock. Sent 24 times per quarter note when synchronization 46 | /// is required. 47 | case timingClock = 0xF8 48 | 49 | /// Start. Message to start the current sequence. 50 | case start = 0xFA 51 | 52 | /// Continue. Continue sequence from last stopped point. 53 | case `continue` = 0xFB 54 | 55 | /// Stop. Stops the current sequence 56 | case stop = 0xFC 57 | 58 | /// Active Sensing. Optional mechanism to keep 59 | /// connections alive. 60 | case activeSensing = 0xFE 61 | 62 | /// System Reset. Should not be sent on power-up, but 63 | /// to reset receivers to their power-up status. 64 | case systemReset = 0xFF 65 | 66 | /// Number of bytes expected in a message with this status 67 | var bytesPerMessage: UInt8 { 68 | switch self { 69 | case .noteOff, .noteOn, .aftertouch, .controlChange, .pitchBendChange: 70 | return 3 71 | case .programChange, .channelPressure: 72 | return 2 73 | case .tuneRequest, .timingClock, .start, .continue, .stop, .activeSensing, .systemReset: 74 | return 1 75 | } 76 | } 77 | 78 | var hasChannel: Bool { 79 | switch self { 80 | case .noteOff, .noteOn, .aftertouch, .controlChange, .programChange, .channelPressure, .pitchBendChange: 81 | return true 82 | case .tuneRequest, .timingClock, .start, .continue, .stop, .activeSensing, .systemReset: 83 | return false 84 | } 85 | } 86 | } 87 | 88 | /// Raw bytes the message is consisting from. 89 | public var bytes: [UInt8] 90 | 91 | /// Kind of the message. 92 | public var status: Status? { 93 | return Status(rawValue: bytes[0]) 94 | } 95 | 96 | /// Channel of the message. Between 0-15. 97 | public var channel: UInt8? { 98 | if let hasChannel = status?.hasChannel, hasChannel { 99 | return bytes[0] & 0x0F 100 | } 101 | return nil 102 | } 103 | 104 | /// First data byte. Usually a key number (note or controller). Between 0-127. 105 | public var data1: UInt8? { 106 | if let bytesPerMessage = status?.bytesPerMessage, bytesPerMessage > 1 { 107 | return bytes[1] 108 | } 109 | return nil 110 | } 111 | 112 | /// Second data byte. Usually a value (pressure, velocity or program number). Between 0-127. 113 | public var data2: UInt8? { 114 | if let bytesPerMessage = status?.bytesPerMessage, bytesPerMessage > 2 { 115 | return bytes[2] 116 | } 117 | return nil 118 | } 119 | 120 | public var isComplete: Bool { 121 | if let bytesPerMessage = status?.bytesPerMessage { 122 | return bytes.count >= bytesPerMessage 123 | } 124 | return false 125 | } 126 | 127 | /// All Notes Off. When an All Notes Off is received, all oscillators will turn off. 128 | static let allNotesOff: MIDIMessage = MIDIMessage(status: .controlChange, channel: 0, data1: 123, data2: 0) 129 | 130 | /// Initializes new message manually with all the parameters. 131 | /// - Parameters: 132 | /// - status: Kind of the message. 133 | /// - channel: Channel of the message. Between 0-15. 134 | /// - data1: First data byte. Usually a key number (note or controller). Between 0-127. 135 | /// - data2: Second data bytes. Usually a value (pressure, velocity or program number). Between 0-127. 136 | public init(status: Status, channel: UInt8? = nil, data1: UInt8? = nil, data2: UInt8? = nil) { 137 | bytes = [status.rawValue | (channel ?? 0)] 138 | if let data1 = data1 { 139 | bytes.append(data1) 140 | } 141 | if let data2 = data2 { 142 | bytes.append(data2) 143 | } 144 | } 145 | 146 | /// Initializes new message automatically by parsing an array of bytes (usually from a MIDI packet). 147 | /// - Parameters: 148 | /// - bytes: Array of bytes . Different message types may be 1, 2, or 3 bytes 149 | /// as determined by the first 4 bits (status). If the number of bytes does not match the expected number as 150 | /// denoted by the status the message will be uncomplete. 151 | public init(bytes: [UInt8] = []) { 152 | self.bytes = bytes 153 | } 154 | } 155 | --------------------------------------------------------------------------------