midi-communications-macos 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,110 @@
1
+ module MIDICommunicationsMacOS
2
+ # A MIDI device may have multiple logically distinct sub-components. For example, one device may
3
+ # encompass a MIDI synthesizer and a pair of MIDI ports, both addressable via a USB port. Each
4
+ # such element of a device is called a MIDI entity.
5
+ #
6
+ # https://developer.apple.com/library/ios/documentation/CoreMidi/Reference/MIDIServices_Reference/Reference/reference.html
7
+ class Device
8
+ attr_reader :entities,
9
+ :id, # Unique Numeric id
10
+ :name # Device name from midi-communications-macos
11
+
12
+ # @param [Integer] id The ID for the device
13
+ # @param [Object] device_pointer The underlying device pointer
14
+ # @param [Boolean] include_offline Whether to include offline entities (default: false)
15
+ def initialize(id, device_pointer, include_offline: false)
16
+ @id = id
17
+ @resource = device_pointer
18
+ @entities = []
19
+ populate(include_offline: include_offline)
20
+ end
21
+
22
+ # Endpoints for this device
23
+ # @return [Array<Endpoint>]
24
+ def endpoints
25
+ endpoints = { source: [], destination: [] }
26
+ endpoints.each_key do |key|
27
+ endpoint_group = entities.map { |entity| entity.endpoints[key] }.flatten
28
+ endpoints[key] += endpoint_group
29
+ end
30
+ endpoints
31
+ end
32
+
33
+ # Assign all of this Device's endpoints an consecutive local id
34
+ # @param [Integer] last_id The highest already used endpoint ID
35
+ # @return [Integer] The highest used endpoint ID after populating this device's endpoints
36
+ def populate_endpoint_ids(last_id)
37
+ id = 0
38
+ entities.each { |entity| id += entity.populate_endpoint_ids(id + last_id) }
39
+ id
40
+ end
41
+
42
+ # All cached devices
43
+ # @param [Hash] options The options to select devices with
44
+ # @option options [Boolean] :cache If false, the device list will never be cached. This would be useful if one needs to alter the device list (e.g. plug in a USB MIDI interface) while their program is running.
45
+ # @option options [Boolean] :include_offline If true, devices marked offline by midi-communications-macos will be included in the list
46
+ # @return [Array<Device>] All cached devices
47
+ def self.all(options = {})
48
+ use_cache = options[:cache] || true
49
+ include_offline = options[:include_offline] || false
50
+ if !populated? || !use_cache
51
+ @devices = []
52
+ counter = 0
53
+ while !(device_pointer = API.MIDIGetDevice(counter)).null?
54
+ device = new(counter, device_pointer, include_offline: include_offline)
55
+ @devices << device
56
+ counter += 1
57
+ end
58
+ populate_endpoint_ids
59
+ end
60
+ @devices
61
+ end
62
+
63
+ # Refresh the Device cache. This is needed if, for example a USB MIDI device is plugged in while the program is running
64
+ # @return [Array<Device>] The Device cache
65
+ def self.refresh
66
+ @devices.clear
67
+ @devices
68
+ end
69
+
70
+ # Has the device list been populated?
71
+ def self.populated?
72
+ defined?(@devices) && !@devices.nil? && !@devices.empty?
73
+ end
74
+
75
+ private
76
+
77
+ # Populate the device name
78
+ def populate_name
79
+ @name = API.get_string(@resource, 'name')
80
+ raise "Can't get device name" unless @name
81
+ end
82
+
83
+ # All of the endpoints for all devices a consecutive local id
84
+ def self.populate_endpoint_ids
85
+ counter = 0
86
+ all.each { |device| counter += device.populate_endpoint_ids(counter) }
87
+ counter
88
+ end
89
+
90
+ # Populates the entities for this device. These entities are in turn used to gather the endpoints.
91
+ # @param [Hash] options
92
+ # @option options [Boolean] :include_offline Whether to include offline entities (default: false)
93
+ # @return [Integer] The number of entities populated
94
+ def populate_entities(options = {})
95
+ include_if_offline = options[:include_offline] || false
96
+ i = 0
97
+ while !(entity_pointer = API.MIDIDeviceGetEntity(@resource, i)).null?
98
+ @entities << Entity.new(entity_pointer, include_offline: include_if_offline)
99
+ i += 1
100
+ end
101
+ i
102
+ end
103
+
104
+ # Populate the instance
105
+ def populate(include_offline:)
106
+ populate_name
107
+ populate_entities(include_offline: include_offline)
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,109 @@
1
+ module MIDICommunicationsMacOS
2
+ # A source or destination of a 16-channel MIDI stream
3
+ #
4
+ # https://developer.apple.com/library/ios/documentation/CoreMidi/Reference/MIDIServices_Reference/Reference/reference.html
5
+ module Endpoint
6
+ extend Forwardable
7
+
8
+ attr_reader :enabled, # has the endpoint been initialized?
9
+ :entity,
10
+ :id, # unique local Numeric id of the endpoint
11
+ :resource_id, # :input or :output
12
+ :type
13
+
14
+ def_delegators :entity, :manufacturer, :model, :name, :display_name
15
+
16
+ alias enabled? enabled
17
+
18
+ # @param [Integer] resource_id
19
+ # @param [Entity] entity
20
+ def initialize(resource_id, entity)
21
+ @entity = entity
22
+ @resource_id = resource_id
23
+ @type = get_type
24
+ @enabled = false
25
+
26
+ @name = nil
27
+
28
+ @threads_sync_semaphore = Mutex.new
29
+ @threads_waiting = []
30
+ end
31
+
32
+ # Is this endpoint online?
33
+ # @return [Boolean]
34
+ def online?
35
+ @entity.online? && connect?
36
+ end
37
+
38
+ # Set the id for this endpoint (the id is immutable)
39
+ # @param [Integer] id
40
+ # @return [Integer]
41
+ def id=(id)
42
+ @id ||= id
43
+ end
44
+
45
+ # Select the first endpoint of the specified type
46
+ # @return [Destination, Source]
47
+ def self.first(type)
48
+ all_by_type[type].first
49
+ end
50
+
51
+ # Select the last endpoint of the specified type
52
+ # @return [Destination, Source]
53
+ def self.last(type)
54
+ all_by_type[type].last
55
+ end
56
+
57
+ # All source endpoints
58
+ # @return [Array<Source>]
59
+ def self.sources
60
+ Device.all.map { |d| d.endpoints[:source] }.flatten
61
+ end
62
+
63
+ # All destination endpoints
64
+ # @return [Array<Destination>]
65
+ def self.destinations
66
+ Device.all.map { |d| d.endpoints[:destination] }.flatten
67
+ end
68
+
69
+ # A Hash of :source and :destination endpoints
70
+ # @return [Hash]
71
+ def self.all_by_type
72
+ {
73
+ source: sources,
74
+ destination: destinations
75
+ }
76
+ end
77
+
78
+ # All endpoints of both types
79
+ # @return [Array<Destination, Source>]
80
+ def self.all
81
+ Device.all.map(&:endpoints).flatten
82
+ end
83
+
84
+ # Get the class for the given endpoint type name
85
+ # @param [Symbol] type The endpoint type eg :source, :destination
86
+ # @return [Class] eg Source, Destination
87
+ def self.get_class(type)
88
+ case type
89
+ when :source then Source
90
+ when :destination then Destination
91
+ end
92
+ end
93
+
94
+ protected
95
+
96
+ # Constructs the endpoint type (eg source, destination) for easy consumption
97
+ def get_type
98
+ class_name = self.class.name.split('::').last
99
+ class_name.downcase.to_sym
100
+ end
101
+
102
+ # Enables the midi-communications-macos MIDI client that will go with this endpoint
103
+ def enable_client
104
+ client = API.create_midi_client(@resource_id, @name)
105
+ @client = client[:resource]
106
+ client[:error]
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,106 @@
1
+ module MIDICommunicationsMacOS
2
+ # A MIDI entity can have any number of MIDI endpoints, each of which is a source or destination
3
+ # of a 16-channel MIDI stream. By grouping a device's endpoints into entities, the system has
4
+ # enough information for an application to make reasonable default assumptions about how to
5
+ # communicate in a bi-directional manner with each entity, as is necessary in MIDI librarian
6
+ # applications.
7
+ #
8
+ # https://developer.apple.com/library/ios/documentation/CoreMidi/Reference/MIDIServices_Reference/Reference/reference.html
9
+ class Entity
10
+ attr_reader :endpoints,
11
+ :manufacturer,
12
+ :model,
13
+ :name,
14
+ :resource
15
+
16
+ # @param [FFI::Pointer] resource A pointer to the underlying entity
17
+ # @param [Boolean] include_offline Include offline endpoints in the list
18
+ def initialize(resource, include_offline: false)
19
+ @endpoints = {
20
+ source: [],
21
+ destination: []
22
+ }
23
+ @resource = resource
24
+ populate(include_offline: include_offline)
25
+ end
26
+
27
+ # Assign all of this Entity's endpoints an consecutive local id
28
+ # @param [Integer] starting_id
29
+ # @return [Integer]
30
+ def populate_endpoint_ids(starting_id)
31
+ counter = 0
32
+ @endpoints.values.flatten.each do |endpoint|
33
+ endpoint.id = counter + starting_id
34
+ counter += 1
35
+ end
36
+ counter
37
+ end
38
+
39
+ # Is the entity online?
40
+ # @return [Boolean]
41
+ def online?
42
+ get_int(:offline).zero?
43
+ end
44
+
45
+ # Construct a display name for the entity
46
+ # @return [String]
47
+ def display_name
48
+ "#{@manufacturer} #{@model} (#{@name})"
49
+ end
50
+
51
+ private
52
+
53
+ # Populate endpoints of a specified type for this entity
54
+ # @param [Symbol] type The endpoint type eg :source, :destination
55
+ # @param [Boolean] include_offline Include offline endpoints in the list
56
+ # @return [Integer]
57
+ def populate_endpoints_by_type(type, include_offline:)
58
+ endpoint_class = Endpoint.get_class(type)
59
+ num_endpoints = number_of_endpoints(type)
60
+ (0..num_endpoints).each do |i|
61
+ endpoint = endpoint_class.new(i, self)
62
+ @endpoints[type] << endpoint if endpoint.online? || include_offline
63
+ end
64
+ @endpoints[type].size
65
+ end
66
+
67
+ # Populate the endpoints for this entity
68
+ # @param [Boolean] include_offline Include offline endpoints in the list
69
+ # @return [Integer]
70
+ def populate_endpoints(include_offline:)
71
+ @endpoints.keys.map { |type| populate_endpoints_by_type(type, include_offline: include_offline) }.reduce(&:+)
72
+ end
73
+
74
+ # The number of endpoints for this entity
75
+ # @param [Symbol] type The endpoint type eg :source, :destination
76
+ def number_of_endpoints(type)
77
+ case type
78
+ when :source then API.MIDIEntityGetNumberOfSources(@resource)
79
+ when :destination then API.MIDIEntityGetNumberOfDestinations(@resource)
80
+ end
81
+ end
82
+
83
+ # A CFString property from the underlying entity
84
+ # @param [Symbol, String] name The property name
85
+ # @return [String, nil]
86
+ def get_string(name)
87
+ API.get_string(@resource, name)
88
+ end
89
+
90
+ # An Integer property from the underlying entity
91
+ # @param [Symbol, String] name The property name
92
+ # @return [Integer, nil]
93
+ def get_int(name)
94
+ API.get_int(@resource, name)
95
+ end
96
+
97
+ # Populate the entity properties from the underlying resource
98
+ # @param [Boolean] include_offline Include offline endpoints in the list
99
+ def populate(include_offline:)
100
+ @manufacturer = get_string(:manufacturer)
101
+ @model = get_string(:model)
102
+ @name = get_string(:name)
103
+ populate_endpoints(include_offline: include_offline)
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,234 @@
1
+ module MIDICommunicationsMacOS
2
+ # Type of endpoint used for input
3
+ class Source
4
+ include Endpoint
5
+
6
+ #
7
+ # An array of MIDI event hashes as such:
8
+ # [
9
+ # { data: [144, 60, 100], timestamp: 1024 },
10
+ # { data: [128, 60, 100], timestamp: 1100 },
11
+ # { data: [144, 40, 120], timestamp: 1200 }
12
+ # ]
13
+ #
14
+ # The data is an array of Numeric bytes
15
+ # The timestamp is the number of millis since this input was enabled
16
+ #
17
+ # @return [Array<Hash>]
18
+ def gets
19
+ get_queue_new_messages
20
+ end
21
+ alias read gets
22
+
23
+ # Same as Source#gets except that it returns message data as string of hex
24
+ # digits as such:
25
+ # [
26
+ # { data: "904060", timestamp: 904 },
27
+ # { data: "804060", timestamp: 1150 },
28
+ # { data: "90447F", timestamp: 1300 }
29
+ # ]
30
+ #
31
+ # @return [Array<Hash>]
32
+ def gets_s
33
+ messages = gets
34
+ messages.each do |message|
35
+ message[:data] = TypeConversion.numeric_bytes_to_hex_string(message[:data])
36
+ end
37
+ messages
38
+ end
39
+ alias gets_bytestr gets_s
40
+
41
+ # Enable this the input for use; can be passed a block
42
+ # @return [Source]
43
+ def enable
44
+ @enabled ||= true
45
+ if block_given?
46
+ begin
47
+ yield(self)
48
+ ensure
49
+ close
50
+ end
51
+ end
52
+ self
53
+ end
54
+ alias open enable
55
+ alias start enable
56
+
57
+ # Close this input
58
+ # @return [Boolean]
59
+ def close
60
+ #error = API.MIDIPortDisconnectSource( @handle, @resource )
61
+ #raise "MIDIPortDisconnectSource returned error code #{error}" unless error.zero?
62
+ #error = API.MIDIClientDispose(@handle)
63
+ #raise "MIDIClientDispose returned error code #{error}" unless error.zero?
64
+ #error = API.MIDIPortDispose(@handle)
65
+ #raise "MIDIPortDispose returned error code #{error}" unless error.zero?
66
+ #error = API.MIDIEndpointDispose(@resource)
67
+ #raise "MIDIEndpointDispose returned error code #{error}" unless error.zero?
68
+ if @enabled
69
+ @enabled = false
70
+ true
71
+ else
72
+ false
73
+ end
74
+ end
75
+
76
+ # Shortcut to the first available input endpoint
77
+ # @return [Source]
78
+ def self.first
79
+ Endpoint.first(:source)
80
+ end
81
+
82
+ # Shortcut to the last available input endpoint
83
+ # @return [Source]
84
+ def self.last
85
+ Endpoint.last(:source)
86
+ end
87
+
88
+ # All input endpoints
89
+ # @return [Array<Source>]
90
+ def self.all
91
+ Endpoint.all_by_type[:source]
92
+ end
93
+
94
+ protected
95
+
96
+ # Gets new received messages from the callback queue
97
+ def get_queue_new_messages
98
+ messages = []
99
+
100
+ if @queue.empty?
101
+ @threads_sync_semaphore.synchronize do
102
+ @threads_waiting << Thread.current
103
+ end
104
+ sleep
105
+ end
106
+
107
+ messages << @queue.pop until @queue.empty?
108
+
109
+ messages
110
+ end
111
+
112
+ # Base initialization for this endpoint -- done whether or not the endpoint is enabled to check whether
113
+ # it is truly available for use
114
+ def connect
115
+ enable_client
116
+ initialize_port
117
+ @resource = API.MIDIEntityGetSource(@entity.resource, @resource_id)
118
+ error = API.MIDIPortConnectSource(@handle, @resource, nil)
119
+
120
+ @queue = Queue.new
121
+ @sysex_buffer = []
122
+
123
+ error.zero?
124
+ end
125
+ alias connect? connect
126
+
127
+ private
128
+
129
+ # Add a single message to the callback queue
130
+ # @param [Array<Fixnum>] bytes Message data
131
+ # @param [Float] timestamp The system float timestamp
132
+ # @return [Array<Hash>] The resulting buffer
133
+ def enqueue_message(bytes, timestamp)
134
+ if bytes.first.eql?(0xF0) || !@sysex_buffer.empty?
135
+ @sysex_buffer += bytes
136
+ if bytes.last.eql?(0xF7)
137
+ bytes = @sysex_buffer.dup
138
+ @sysex_buffer.clear
139
+ end
140
+ end
141
+ message = get_message_formatted(bytes, timestamp)
142
+ @queue << message
143
+
144
+ @threads_sync_semaphore.synchronize do
145
+ @threads_waiting.each(&:run)
146
+ @threads_waiting.clear
147
+ end
148
+
149
+ message
150
+ end
151
+
152
+ # The callback fired by midi-communications-macos when new MIDI messages are received
153
+ def get_event_callback
154
+ Thread.abort_on_exception = true
155
+ proc do |new_packets, _refcon_ptr, _connrefcon_ptr|
156
+ begin
157
+ # p "packets received: #{new_packets[:numPackets]}"
158
+ timestamp = Time.now.to_f
159
+ messages = get_messages(new_packets)
160
+ messages.each do |message|
161
+ enqueue_message(message, timestamp)
162
+ end
163
+ rescue Exception => exception
164
+ Thread.main.raise(exception)
165
+ end
166
+ end
167
+ end
168
+
169
+ # Get MIDI messages from the given CoreMIDI packet list
170
+ # @param [API::MIDIPacketList] packet_list The packet list
171
+ # @return [Array<Array<Fixnum>>] A collection of MIDI messages
172
+ def get_messages(packet_list)
173
+ count = packet_list[:numPackets]
174
+ first = packet_list[:packet][0]
175
+ data = first[:data].to_a
176
+ messages = []
177
+ messages << data.slice!(0, first[:length])
178
+ (count - 1).times do |i|
179
+ length_index = find_next_length_index(data)
180
+ message_length = data[length_index]
181
+
182
+ next if message_length.nil?
183
+
184
+ packet_start_index = length_index + 2
185
+ packet_end_index = packet_start_index + message_length
186
+
187
+ next unless data.length >= packet_end_index + 1
188
+
189
+ packet = data.slice!(0..packet_end_index)
190
+ message = packet.slice(packet_start_index, message_length)
191
+ messages << message
192
+ end
193
+ messages
194
+ end
195
+
196
+ # Get the next index for "length" from the blob of MIDI data
197
+ # @param [Array<Fixnum>] data
198
+ # @return [Fixnum]
199
+ def find_next_length_index(data)
200
+ last_is_zero = false
201
+ data.each_with_index do |num, i|
202
+ if num.zero?
203
+ if last_is_zero
204
+ return i + 1
205
+ else
206
+ last_is_zero = true
207
+ end
208
+ else
209
+ last_is_zero = false
210
+ end
211
+ end
212
+ end
213
+
214
+ # Give a message its timestamp and package it in a Hash
215
+ # @return [Hash]
216
+ def get_message_formatted(raw, time)
217
+ {
218
+ data: raw,
219
+ timestamp: time
220
+ }
221
+ end
222
+
223
+ # Initialize a midi-communications-macos port for this endpoint
224
+ # @return [Boolean]
225
+ def initialize_port
226
+ @callback = get_event_callback
227
+ port = API.create_midi_input_port(@client, @resource_id, @name, @callback)
228
+ @handle = port[:handle]
229
+ raise "MIDIInputPortCreate returned error code #{port[:error]}" unless port[:error].zero?
230
+
231
+ true
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,18 @@
1
+ module MIDICommunicationsMacOS
2
+ # Helper for convertig MIDI data
3
+ module TypeConversion
4
+ extend self
5
+
6
+ # Convert an array of numeric byes to a hex string (e.g. [0x90, 0x40, 0x40] becomes "904040")
7
+ # @param [Array<Integer>] bytes
8
+ # @return [String]
9
+ def numeric_bytes_to_hex_string(bytes)
10
+ string_bytes = bytes.map do |byte|
11
+ str = byte.to_s(16).upcase
12
+ str = "0#{str}" if byte < 16
13
+ str
14
+ end
15
+ string_bytes.join
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,26 @@
1
+ #
2
+ # midi-communications-macos
3
+ # Realtime MIDI IO with Ruby for OSX
4
+ #
5
+ # (c)2021 Javier Sánchez Yeste for the modifications, licensed under LGPL 3.0 License
6
+ # (c)2011-2017 Ari Russo
7
+ #
8
+
9
+ # Libs
10
+ require 'ffi'
11
+ require 'forwardable'
12
+
13
+ # Modules
14
+ require 'midi-communications-macos/api'
15
+ require 'midi-communications-macos/endpoint'
16
+ require 'midi-communications-macos/type_conversion'
17
+
18
+ # Classes
19
+ require 'midi-communications-macos/entity'
20
+ require 'midi-communications-macos/device'
21
+ require 'midi-communications-macos/source'
22
+ require 'midi-communications-macos/destination'
23
+
24
+ module MIDICommunicationsMacOS
25
+ VERSION = '0.5.0'.freeze
26
+ end
@@ -0,0 +1,24 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'midi-communications-macos'
3
+ s.version = '0.5.0'
4
+ s.date = '2021-11-15'
5
+ s.summary = 'Realtime MIDI IO with Ruby for OSX'
6
+ s.description = 'Access the Apple Core MIDI framework API with Ruby.'
7
+ s.authors = ['Javier Sánchez Yeste']
8
+ s.email = ['javier.sy@gmail.com']
9
+ s.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
10
+ s.homepage = 'http://rubygems.org/gems/midi-communications-macos'
11
+ s.license = 'LGPL-3.0'
12
+
13
+ s.required_ruby_version = '~> 2.7'
14
+
15
+ s.add_runtime_dependency 'ffi', '~> 1.15', '>= 1.15.4'
16
+
17
+ # TODO
18
+ #s.metadata = {
19
+ # "source_code_uri" => "https://",
20
+ # "homepage_uri" => "",
21
+ # "documentation_uri" => "",
22
+ # "changelog_uri" => ""
23
+ #}
24
+ end