rtmidi-ruby 0.1.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,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rtmidi
4
+ module Message
5
+ NoteOff = Struct.new(:channel, :note, :velocity, keyword_init: true)
6
+ NoteOn = Struct.new(:channel, :note, :velocity, keyword_init: true)
7
+ PolyAftertouch = Struct.new(:channel, :note, :pressure, keyword_init: true)
8
+ ControlChange = Struct.new(:channel, :controller, :value, keyword_init: true)
9
+ ProgramChange = Struct.new(:channel, :program, keyword_init: true)
10
+ ChannelAftertouch = Struct.new(:channel, :pressure, keyword_init: true)
11
+ PitchBend = Struct.new(:channel, :value, keyword_init: true)
12
+ SysEx = Struct.new(:data, keyword_init: true)
13
+ TimeCodeQuarterFrame = Struct.new(:message_type, :value, keyword_init: true)
14
+ SongPositionPointer = Struct.new(:position, keyword_init: true)
15
+ SongSelect = Struct.new(:song, keyword_init: true)
16
+ TuneRequest = Struct.new(nil, keyword_init: true)
17
+ TimingClock = Struct.new(nil, keyword_init: true)
18
+ Start = Struct.new(nil, keyword_init: true)
19
+ Continue = Struct.new(nil, keyword_init: true)
20
+ Stop = Struct.new(nil, keyword_init: true)
21
+ ActiveSensing = Struct.new(nil, keyword_init: true)
22
+ SystemReset = Struct.new(nil, keyword_init: true)
23
+ Unknown = Struct.new(:bytes, keyword_init: true)
24
+
25
+ class << self
26
+ def parse(message)
27
+ bytes = normalize_bytes(message, allow_empty: false)
28
+ status = bytes[0]
29
+
30
+ return parse_system_message(status, bytes) if status >= 0xF0
31
+
32
+ parse_channel_message(status, bytes)
33
+ end
34
+
35
+ def serialize(message)
36
+ case message
37
+ when Array
38
+ normalize_bytes(message, allow_empty: false)
39
+ when NoteOff
40
+ [status_byte(0x80, message.channel), data_byte(message.note, "note"), data_byte(message.velocity, "velocity")]
41
+ when NoteOn
42
+ [status_byte(0x90, message.channel), data_byte(message.note, "note"), data_byte(message.velocity, "velocity")]
43
+ when PolyAftertouch
44
+ [status_byte(0xA0, message.channel), data_byte(message.note, "note"), data_byte(message.pressure, "pressure")]
45
+ when ControlChange
46
+ [status_byte(0xB0, message.channel), data_byte(message.controller, "controller"), data_byte(message.value, "value")]
47
+ when ProgramChange
48
+ [status_byte(0xC0, message.channel), data_byte(message.program, "program")]
49
+ when ChannelAftertouch
50
+ [status_byte(0xD0, message.channel), data_byte(message.pressure, "pressure")]
51
+ when PitchBend
52
+ bend = integer_in_range(message.value, 0, 16_383, "value")
53
+ [status_byte(0xE0, message.channel), bend & 0x7F, (bend >> 7) & 0x7F]
54
+ when SysEx
55
+ serialize_sysex(message.data)
56
+ when TimeCodeQuarterFrame
57
+ message_type = integer_in_range(message.message_type, 0, 7, "message_type")
58
+ value = integer_in_range(message.value, 0, 15, "value")
59
+ [0xF1, (message_type << 4) | value]
60
+ when SongPositionPointer
61
+ position = integer_in_range(message.position, 0, 16_383, "position")
62
+ [0xF2, position & 0x7F, (position >> 7) & 0x7F]
63
+ when SongSelect
64
+ [0xF3, data_byte(message.song, "song")]
65
+ when TuneRequest
66
+ [0xF6]
67
+ when TimingClock
68
+ [0xF8]
69
+ when Start
70
+ [0xFA]
71
+ when Continue
72
+ [0xFB]
73
+ when Stop
74
+ [0xFC]
75
+ when ActiveSensing
76
+ [0xFE]
77
+ when SystemReset
78
+ [0xFF]
79
+ when Unknown
80
+ normalize_bytes(message.bytes, allow_empty: false)
81
+ else
82
+ raise ArgumentError, "unsupported MIDI message: #{message.inspect}"
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def parse_channel_message(status, bytes)
89
+ channel = status & 0x0F
90
+
91
+ case status & 0xF0
92
+ when 0x80
93
+ expect_length!(bytes, 3, "note off")
94
+ NoteOff.new(channel: channel, note: bytes[1], velocity: bytes[2])
95
+ when 0x90
96
+ expect_length!(bytes, 3, "note on")
97
+ NoteOn.new(channel: channel, note: bytes[1], velocity: bytes[2])
98
+ when 0xA0
99
+ expect_length!(bytes, 3, "poly aftertouch")
100
+ PolyAftertouch.new(channel: channel, note: bytes[1], pressure: bytes[2])
101
+ when 0xB0
102
+ expect_length!(bytes, 3, "control change")
103
+ ControlChange.new(channel: channel, controller: bytes[1], value: bytes[2])
104
+ when 0xC0
105
+ expect_length!(bytes, 2, "program change")
106
+ ProgramChange.new(channel: channel, program: bytes[1])
107
+ when 0xD0
108
+ expect_length!(bytes, 2, "channel aftertouch")
109
+ ChannelAftertouch.new(channel: channel, pressure: bytes[1])
110
+ when 0xE0
111
+ expect_length!(bytes, 3, "pitch bend")
112
+ PitchBend.new(channel: channel, value: bytes[1] | (bytes[2] << 7))
113
+ else
114
+ Unknown.new(bytes: bytes)
115
+ end
116
+ end
117
+
118
+ def parse_system_message(status, bytes)
119
+ case status
120
+ when 0xF0
121
+ SysEx.new(data: bytes[1..-2] || [])
122
+ when 0xF1
123
+ expect_length!(bytes, 2, "time code quarter frame")
124
+ TimeCodeQuarterFrame.new(message_type: (bytes[1] >> 4) & 0x07, value: bytes[1] & 0x0F)
125
+ when 0xF2
126
+ expect_length!(bytes, 3, "song position pointer")
127
+ SongPositionPointer.new(position: bytes[1] | (bytes[2] << 7))
128
+ when 0xF3
129
+ expect_length!(bytes, 2, "song select")
130
+ SongSelect.new(song: bytes[1])
131
+ when 0xF6
132
+ TuneRequest.new
133
+ when 0xF8
134
+ TimingClock.new
135
+ when 0xFA
136
+ Start.new
137
+ when 0xFB
138
+ Continue.new
139
+ when 0xFC
140
+ Stop.new
141
+ when 0xFE
142
+ ActiveSensing.new
143
+ when 0xFF
144
+ SystemReset.new
145
+ else
146
+ Unknown.new(bytes: bytes)
147
+ end
148
+ end
149
+
150
+ def serialize_sysex(data)
151
+ bytes = normalize_bytes(data, allow_empty: true)
152
+ return [0xF0, 0xF7] if bytes.empty?
153
+
154
+ return bytes if bytes[0] == 0xF0 && bytes[-1] == 0xF7
155
+ raise ArgumentError, "sysex data with 0xF0 prefix must end with 0xF7" if bytes[0] == 0xF0
156
+
157
+ [0xF0, *bytes, 0xF7]
158
+ end
159
+
160
+ def status_byte(base, channel)
161
+ base | integer_in_range(channel, 0, 15, "channel")
162
+ end
163
+
164
+ def data_byte(value, label)
165
+ integer_in_range(value, 0, 127, label)
166
+ end
167
+
168
+ def integer_in_range(value, min, max, label)
169
+ integer = begin
170
+ Integer(value)
171
+ rescue ArgumentError, TypeError
172
+ raise ArgumentError, "#{label} must be an Integer"
173
+ end
174
+
175
+ return integer if integer >= min && integer <= max
176
+
177
+ raise ArgumentError, "#{label} must be within #{min}..#{max}"
178
+ end
179
+
180
+ def normalize_bytes(message, allow_empty:)
181
+ raise ArgumentError, "message must be an Array" unless message.is_a?(Array)
182
+ raise ArgumentError, "message must not be empty" if !allow_empty && message.empty?
183
+
184
+ message.each_with_index.map do |byte, index|
185
+ integer_in_range(byte, 0, 255, "message[#{index}]")
186
+ end
187
+ end
188
+
189
+ def expect_length!(bytes, minimum, label)
190
+ return if bytes.length >= minimum
191
+
192
+ raise ArgumentError, "#{label} message must contain at least #{minimum} bytes"
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rtmidi
4
+ class MidiIn
5
+ DEFAULT_CLIENT_NAME = "Rtmidi Ruby Client"
6
+ DEFAULT_PORT_NAME = "Rtmidi Input"
7
+ DEFAULT_QUEUE_SIZE_LIMIT = 1024
8
+ POLL_BUFFER_SIZE = 65_536
9
+
10
+ class Release
11
+ def initialize(handle)
12
+ @handle = handle
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ def call(*)
17
+ free
18
+ end
19
+
20
+ def free
21
+ @mutex.synchronize do
22
+ return if @handle.nil? || @handle.null?
23
+
24
+ Native.rtmidi_in_free(@handle)
25
+ @handle = nil
26
+ end
27
+ end
28
+ end
29
+
30
+ def initialize(api: :unspecified, client_name: DEFAULT_CLIENT_NAME, queue_size_limit: DEFAULT_QUEUE_SIZE_LIMIT)
31
+ Native.ensure_loaded!
32
+ queue_limit = integer_for(queue_size_limit, "queue_size_limit")
33
+
34
+ @mutex = Mutex.new
35
+ @port_open = false
36
+ @closed = false
37
+ @last_async_error = nil
38
+ @user_error_callback = nil
39
+ @error_callback = nil
40
+ @message_callback = nil
41
+
42
+ @handle = Native.rtmidi_in_create(Api.normalize(api), client_name.to_s, queue_limit)
43
+ raise Error, "failed to create RtMidi input device" if @handle.nil? || @handle.null?
44
+
45
+ @release = Release.new(@handle)
46
+ ObjectSpace.define_finalizer(self, @release)
47
+
48
+ setup_error_callback
49
+ Native.check_error(@handle)
50
+ end
51
+
52
+ def port_count
53
+ with_device_lock do
54
+ port_count_locked
55
+ end
56
+ end
57
+
58
+ def port_name(number)
59
+ with_device_lock do
60
+ port_name_locked(normalize_port_number(number, port_count_locked))
61
+ end
62
+ end
63
+
64
+ def port_names
65
+ with_device_lock do
66
+ count = port_count_locked
67
+ Array.new(count) { |index| port_name_locked(index) }
68
+ end
69
+ end
70
+
71
+ def open_port(number, name: DEFAULT_PORT_NAME)
72
+ with_device_lock do
73
+ index = normalize_port_number(number, port_count_locked)
74
+ Native.rtmidi_open_port(@handle, index, name.to_s)
75
+ Native.check_error(@handle)
76
+ @port_open = true
77
+ end
78
+ self
79
+ end
80
+
81
+ def open_virtual_port(name: "Rtmidi Virtual Input")
82
+ with_device_lock do
83
+ Native.rtmidi_open_virtual_port(@handle, name.to_s)
84
+ Native.check_error(@handle)
85
+ @port_open = true
86
+ end
87
+ self
88
+ end
89
+
90
+ def close_port
91
+ with_device_lock do
92
+ Native.rtmidi_close_port(@handle)
93
+ Native.check_error(@handle)
94
+ @port_open = false
95
+ end
96
+ self
97
+ end
98
+
99
+ def port_open?
100
+ @mutex.synchronize { @port_open }
101
+ end
102
+
103
+ def current_api
104
+ with_device_lock do
105
+ Api.normalize(Native.rtmidi_in_get_current_api(@handle))
106
+ end
107
+ end
108
+
109
+ def current_api_name
110
+ Rtmidi.api_name(current_api)
111
+ end
112
+
113
+ def current_api_display_name
114
+ Rtmidi.api_display_name(current_api)
115
+ end
116
+
117
+ def set_callback(parsed: false, &block)
118
+ raise ArgumentError, "block required" unless block
119
+
120
+ with_device_lock do
121
+ raise InvalidUseError, "callback already set" if @message_callback
122
+
123
+ @message_callback = proc do |timestamp, msg_ptr, msg_size, _user_data|
124
+ bytes = if msg_ptr.nil? || msg_ptr.null? || msg_size.to_i <= 0
125
+ []
126
+ else
127
+ msg_ptr.read_array_of_uint8(msg_size.to_i)
128
+ end
129
+ payload = parsed ? Rtmidi::Message.parse(bytes) : bytes
130
+ block.call(payload, timestamp)
131
+ rescue StandardError => e
132
+ @mutex.synchronize { @last_async_error ||= e }
133
+ end
134
+
135
+ Native.rtmidi_in_set_callback(@handle, @message_callback, nil)
136
+ Native.check_error(@handle)
137
+ end
138
+ self
139
+ end
140
+ alias on_message set_callback
141
+
142
+ def cancel_callback
143
+ with_device_lock do
144
+ if @message_callback
145
+ Native.rtmidi_in_cancel_callback(@handle)
146
+ Native.check_error(@handle)
147
+ @message_callback = nil
148
+ end
149
+ end
150
+ self
151
+ end
152
+
153
+ def get_message(parsed: false)
154
+ with_device_lock do
155
+ raise InvalidUseError, "get_message cannot be used while callback is set" if @message_callback
156
+
157
+ buffer = FFI::MemoryPointer.new(:uint8, POLL_BUFFER_SIZE)
158
+ size_ptr = FFI::MemoryPointer.new(:size_t, 1)
159
+ Native.write_size_t(size_ptr, POLL_BUFFER_SIZE)
160
+
161
+ timestamp = Native.rtmidi_in_get_message(@handle, buffer, size_ptr)
162
+ Native.check_error(@handle)
163
+
164
+ size = Native.read_size_t(size_ptr)
165
+ return nil if size.zero?
166
+
167
+ bytes = buffer.read_array_of_uint8(size)
168
+ [parsed ? Rtmidi::Message.parse(bytes) : bytes, timestamp]
169
+ end
170
+ end
171
+
172
+ def ignore_types(sysex: true, timing: true, active_sensing: true)
173
+ with_device_lock do
174
+ Native.rtmidi_in_ignore_types(@handle, !!sysex, !!timing, !!active_sensing)
175
+ Native.check_error(@handle)
176
+ end
177
+ self
178
+ end
179
+
180
+ def on_error(&block)
181
+ raise ArgumentError, "block required" unless block
182
+
183
+ @mutex.synchronize { @user_error_callback = block }
184
+ self
185
+ end
186
+
187
+ def close
188
+ @mutex.synchronize do
189
+ return if @closed
190
+
191
+ begin
192
+ if @message_callback
193
+ Native.rtmidi_in_cancel_callback(@handle)
194
+ Native.check_error(@handle)
195
+ @message_callback = nil
196
+ end
197
+
198
+ if @port_open
199
+ Native.rtmidi_close_port(@handle)
200
+ Native.check_error(@handle)
201
+ @port_open = false
202
+ end
203
+ ensure
204
+ @release&.free
205
+ @handle = nil
206
+ @closed = true
207
+ end
208
+ end
209
+ nil
210
+ end
211
+
212
+ def closed?
213
+ @mutex.synchronize { @closed }
214
+ end
215
+
216
+ private
217
+
218
+ def setup_error_callback
219
+ return unless Native.function_available?(:rtmidi_set_error_callback)
220
+
221
+ @error_callback = proc do |type, error_text, _user_data|
222
+ callback = @mutex.synchronize { @user_error_callback }
223
+
224
+ if callback
225
+ callback.call(type, error_text)
226
+ elsif Rtmidi.warning_type?(type)
227
+ warn "[Rtmidi #{type}] #{error_text}"
228
+ else
229
+ @mutex.synchronize { @last_async_error ||= Rtmidi.error_for(type, error_text) }
230
+ end
231
+ rescue StandardError => e
232
+ @mutex.synchronize { @last_async_error ||= e }
233
+ end
234
+
235
+ Native.rtmidi_set_error_callback(@handle, @error_callback, nil)
236
+ Native.check_error(@handle)
237
+ end
238
+
239
+ def with_device_lock
240
+ @mutex.synchronize do
241
+ raise InvalidUseError, "device is closed" if @closed
242
+
243
+ raise_pending_async_error!
244
+ yield
245
+ end
246
+ end
247
+
248
+ def raise_pending_async_error!
249
+ error = @last_async_error
250
+ @last_async_error = nil
251
+ raise error unless error.nil?
252
+ end
253
+
254
+ def port_count_locked
255
+ count = Native.rtmidi_get_port_count(@handle)
256
+ Native.check_error(@handle)
257
+ count
258
+ end
259
+
260
+ def port_name_locked(number)
261
+ len_ptr = FFI::MemoryPointer.new(:int, 1)
262
+ len_ptr.write_int(0)
263
+ Native.rtmidi_get_port_name(@handle, number, nil, len_ptr)
264
+ Native.check_error(@handle)
265
+
266
+ length = len_ptr.read_int
267
+ return "" if length <= 0
268
+
269
+ buffer = FFI::MemoryPointer.new(:char, length)
270
+ len_ptr.write_int(length)
271
+ Native.rtmidi_get_port_name(@handle, number, buffer, len_ptr)
272
+ Native.check_error(@handle)
273
+ buffer.read_string
274
+ end
275
+
276
+ def normalize_port_number(number, count)
277
+ port = integer_for(number, "port")
278
+ raise NoDevicesError, "no MIDI ports available" if count.zero?
279
+
280
+ return port if port >= 0 && port < count
281
+
282
+ raise InvalidPortError, "invalid port index #{port} (available: 0...#{count})"
283
+ end
284
+
285
+ def integer_for(value, label)
286
+ Integer(value)
287
+ rescue ArgumentError, TypeError
288
+ raise ArgumentError, "#{label} must be an Integer"
289
+ end
290
+ end
291
+ end