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,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rtmidi
4
+ class MidiOut
5
+ DEFAULT_CLIENT_NAME = "Rtmidi Ruby Client"
6
+ DEFAULT_PORT_NAME = "Rtmidi Output"
7
+
8
+ class Release
9
+ def initialize(handle)
10
+ @handle = handle
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ def call(*)
15
+ free
16
+ end
17
+
18
+ def free
19
+ @mutex.synchronize do
20
+ return if @handle.nil? || @handle.null?
21
+
22
+ Native.rtmidi_out_free(@handle)
23
+ @handle = nil
24
+ end
25
+ end
26
+ end
27
+
28
+ def initialize(api: :unspecified, client_name: DEFAULT_CLIENT_NAME)
29
+ Native.ensure_loaded!
30
+
31
+ @mutex = Mutex.new
32
+ @port_open = false
33
+ @closed = false
34
+ @last_async_error = nil
35
+ @user_error_callback = nil
36
+ @error_callback = nil
37
+
38
+ @handle = Native.rtmidi_out_create(Api.normalize(api), client_name.to_s)
39
+ raise Error, "failed to create RtMidi output device" if @handle.nil? || @handle.null?
40
+
41
+ @release = Release.new(@handle)
42
+ ObjectSpace.define_finalizer(self, @release)
43
+
44
+ setup_error_callback
45
+ Native.check_error(@handle)
46
+ end
47
+
48
+ def port_count
49
+ with_device_lock do
50
+ port_count_locked
51
+ end
52
+ end
53
+
54
+ def port_name(number)
55
+ with_device_lock do
56
+ port_name_locked(normalize_port_number(number, port_count_locked))
57
+ end
58
+ end
59
+
60
+ def port_names
61
+ with_device_lock do
62
+ count = port_count_locked
63
+ Array.new(count) { |index| port_name_locked(index) }
64
+ end
65
+ end
66
+
67
+ def open_port(number, name: DEFAULT_PORT_NAME)
68
+ with_device_lock do
69
+ index = normalize_port_number(number, port_count_locked)
70
+ Native.rtmidi_open_port(@handle, index, name.to_s)
71
+ Native.check_error(@handle)
72
+ @port_open = true
73
+ end
74
+ self
75
+ end
76
+
77
+ def open_virtual_port(name: "Rtmidi Virtual Output")
78
+ with_device_lock do
79
+ Native.rtmidi_open_virtual_port(@handle, name.to_s)
80
+ Native.check_error(@handle)
81
+ @port_open = true
82
+ end
83
+ self
84
+ end
85
+
86
+ def close_port
87
+ with_device_lock do
88
+ Native.rtmidi_close_port(@handle)
89
+ Native.check_error(@handle)
90
+ @port_open = false
91
+ end
92
+ self
93
+ end
94
+
95
+ def port_open?
96
+ @mutex.synchronize { @port_open }
97
+ end
98
+
99
+ def current_api
100
+ with_device_lock do
101
+ Api.normalize(Native.rtmidi_out_get_current_api(@handle))
102
+ end
103
+ end
104
+
105
+ def current_api_name
106
+ Rtmidi.api_name(current_api)
107
+ end
108
+
109
+ def current_api_display_name
110
+ Rtmidi.api_display_name(current_api)
111
+ end
112
+
113
+ def send_message(message)
114
+ bytes = normalize_message(message)
115
+
116
+ with_device_lock do
117
+ raise InvalidUseError, "port is not open" unless @port_open
118
+
119
+ buffer = FFI::MemoryPointer.new(:uint8, bytes.length)
120
+ buffer.write_array_of_uint8(bytes)
121
+ Native.rtmidi_out_send_message(@handle, buffer, bytes.length)
122
+ Native.check_error(@handle)
123
+ end
124
+ self
125
+ end
126
+
127
+ def sysex(data)
128
+ send_message(Rtmidi::Message::SysEx.new(data: data))
129
+ end
130
+
131
+ def note_on(channel, note, velocity)
132
+ send_message([
133
+ status_byte(0x90, channel),
134
+ data_byte(note, "note"),
135
+ data_byte(velocity, "velocity")
136
+ ])
137
+ end
138
+
139
+ def note_off(channel, note, velocity: 0)
140
+ send_message([
141
+ status_byte(0x80, channel),
142
+ data_byte(note, "note"),
143
+ data_byte(velocity, "velocity")
144
+ ])
145
+ end
146
+
147
+ def control_change(channel, controller, value)
148
+ send_message([
149
+ status_byte(0xB0, channel),
150
+ data_byte(controller, "controller"),
151
+ data_byte(value, "value")
152
+ ])
153
+ end
154
+
155
+ def program_change(channel, program)
156
+ send_message([
157
+ status_byte(0xC0, channel),
158
+ data_byte(program, "program")
159
+ ])
160
+ end
161
+
162
+ def pitch_bend(channel, value)
163
+ bend = normalize_range(value, 0, 16_383, "value")
164
+ send_message([
165
+ status_byte(0xE0, channel),
166
+ bend & 0x7F,
167
+ (bend >> 7) & 0x7F
168
+ ])
169
+ end
170
+
171
+ def channel_aftertouch(channel, pressure)
172
+ send_message([
173
+ status_byte(0xD0, channel),
174
+ data_byte(pressure, "pressure")
175
+ ])
176
+ end
177
+
178
+ def poly_aftertouch(channel, note, pressure)
179
+ send_message([
180
+ status_byte(0xA0, channel),
181
+ data_byte(note, "note"),
182
+ data_byte(pressure, "pressure")
183
+ ])
184
+ end
185
+
186
+ def time_code_quarter_frame(message_type, value)
187
+ send_message(Rtmidi::Message::TimeCodeQuarterFrame.new(message_type: message_type, value: value))
188
+ end
189
+
190
+ def song_position_pointer(position)
191
+ send_message(Rtmidi::Message::SongPositionPointer.new(position: position))
192
+ end
193
+
194
+ def song_select(song)
195
+ send_message(Rtmidi::Message::SongSelect.new(song: song))
196
+ end
197
+
198
+ def tune_request
199
+ send_message(Rtmidi::Message::TuneRequest.new)
200
+ end
201
+
202
+ def timing_clock
203
+ send_message(Rtmidi::Message::TimingClock.new)
204
+ end
205
+
206
+ def start
207
+ send_message(Rtmidi::Message::Start.new)
208
+ end
209
+
210
+ def continue
211
+ send_message(Rtmidi::Message::Continue.new)
212
+ end
213
+
214
+ def stop
215
+ send_message(Rtmidi::Message::Stop.new)
216
+ end
217
+
218
+ def active_sensing
219
+ send_message(Rtmidi::Message::ActiveSensing.new)
220
+ end
221
+
222
+ def system_reset
223
+ send_message(Rtmidi::Message::SystemReset.new)
224
+ end
225
+
226
+ def nrpn(channel, parameter, value)
227
+ parameter_number = normalize_range(parameter, 0, 16_383, "parameter")
228
+ data_value = normalize_range(value, 0, 16_383, "value")
229
+
230
+ control_change(channel, 99, (parameter_number >> 7) & 0x7F)
231
+ control_change(channel, 98, parameter_number & 0x7F)
232
+ control_change(channel, 6, (data_value >> 7) & 0x7F)
233
+ control_change(channel, 38, data_value & 0x7F)
234
+ self
235
+ end
236
+
237
+ def on_error(&block)
238
+ raise ArgumentError, "block required" unless block
239
+
240
+ @mutex.synchronize { @user_error_callback = block }
241
+ self
242
+ end
243
+
244
+ def close
245
+ @mutex.synchronize do
246
+ return if @closed
247
+
248
+ begin
249
+ if @port_open
250
+ Native.rtmidi_close_port(@handle)
251
+ Native.check_error(@handle)
252
+ @port_open = false
253
+ end
254
+ ensure
255
+ @release&.free
256
+ @handle = nil
257
+ @closed = true
258
+ end
259
+ end
260
+ nil
261
+ end
262
+
263
+ def closed?
264
+ @mutex.synchronize { @closed }
265
+ end
266
+
267
+ private
268
+
269
+ def setup_error_callback
270
+ return unless Native.function_available?(:rtmidi_set_error_callback)
271
+
272
+ @error_callback = proc do |type, error_text, _user_data|
273
+ callback = @mutex.synchronize { @user_error_callback }
274
+
275
+ if callback
276
+ callback.call(type, error_text)
277
+ elsif Rtmidi.warning_type?(type)
278
+ warn "[Rtmidi #{type}] #{error_text}"
279
+ else
280
+ @mutex.synchronize { @last_async_error ||= Rtmidi.error_for(type, error_text) }
281
+ end
282
+ rescue StandardError => e
283
+ @mutex.synchronize { @last_async_error ||= e }
284
+ end
285
+
286
+ Native.rtmidi_set_error_callback(@handle, @error_callback, nil)
287
+ Native.check_error(@handle)
288
+ end
289
+
290
+ def with_device_lock
291
+ @mutex.synchronize do
292
+ raise InvalidUseError, "device is closed" if @closed
293
+
294
+ raise_pending_async_error!
295
+ yield
296
+ end
297
+ end
298
+
299
+ def raise_pending_async_error!
300
+ error = @last_async_error
301
+ @last_async_error = nil
302
+ raise error unless error.nil?
303
+ end
304
+
305
+ def port_count_locked
306
+ count = Native.rtmidi_get_port_count(@handle)
307
+ Native.check_error(@handle)
308
+ count
309
+ end
310
+
311
+ def port_name_locked(number)
312
+ len_ptr = FFI::MemoryPointer.new(:int, 1)
313
+ len_ptr.write_int(0)
314
+ Native.rtmidi_get_port_name(@handle, number, nil, len_ptr)
315
+ Native.check_error(@handle)
316
+
317
+ length = len_ptr.read_int
318
+ return "" if length <= 0
319
+
320
+ buffer = FFI::MemoryPointer.new(:char, length)
321
+ len_ptr.write_int(length)
322
+ Native.rtmidi_get_port_name(@handle, number, buffer, len_ptr)
323
+ Native.check_error(@handle)
324
+ buffer.read_string
325
+ end
326
+
327
+ def normalize_port_number(number, count)
328
+ port = integer_for(number, "port")
329
+ raise NoDevicesError, "no MIDI ports available" if count.zero?
330
+
331
+ return port if port >= 0 && port < count
332
+
333
+ raise InvalidPortError, "invalid port index #{port} (available: 0...#{count})"
334
+ end
335
+
336
+ def normalize_message(message)
337
+ Rtmidi::Message.serialize(message)
338
+ end
339
+
340
+ def status_byte(base, channel)
341
+ base | normalize_range(channel, 0, 15, "channel")
342
+ end
343
+
344
+ def data_byte(value, label)
345
+ normalize_range(value, 0, 127, label)
346
+ end
347
+
348
+ def normalize_range(value, min, max, label)
349
+ integer = integer_for(value, label)
350
+ return integer if integer >= min && integer <= max
351
+
352
+ raise ArgumentError, "#{label} must be within #{min}..#{max}"
353
+ end
354
+
355
+ def integer_for(value, label)
356
+ Integer(value)
357
+ rescue ArgumentError, TypeError
358
+ raise ArgumentError, "#{label} must be an Integer"
359
+ end
360
+ end
361
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+
5
+ module Rtmidi
6
+ module Native
7
+ extend FFI::Library
8
+
9
+ LOAD_ERROR_MESSAGE = <<~MSG.freeze
10
+ librtmidi が見つかりません。
11
+ macOS: brew install rtmidi
12
+ Ubuntu/Debian: sudo apt install librtmidi-dev
13
+ 環境変数 RTMIDI_LIB_PATH でパスを指定することもできます。
14
+ MSG
15
+
16
+ DEFAULT_LIBRARIES = [
17
+ "rtmidi",
18
+ "librtmidi",
19
+ "librtmidi.so.7",
20
+ "librtmidi.so",
21
+ "librtmidi.dylib",
22
+ "/opt/homebrew/lib/librtmidi.dylib",
23
+ "/usr/local/lib/librtmidi.dylib",
24
+ "rtmidi.dll",
25
+ "librtmidi.dll"
26
+ ].freeze
27
+
28
+ class Wrapper < FFI::Struct
29
+ layout :ptr, :pointer,
30
+ :data, :pointer,
31
+ :ok, :bool,
32
+ :msg, :pointer
33
+ end
34
+
35
+ enum :rtmidi_api, [
36
+ :unspecified, 0,
37
+ :macosx_core, 1,
38
+ :linux_alsa, 2,
39
+ :unix_jack, 3,
40
+ :windows_mm, 4,
41
+ :rtmidi_dummy, 5,
42
+ :web_midi_api, 6,
43
+ :windows_uwp, 7,
44
+ :android_amidi, 8,
45
+ :num_apis, 9
46
+ ]
47
+
48
+ enum :rtmidi_error_type, [
49
+ :warning, 0,
50
+ :debug_warning, 1,
51
+ :unspecified, 2,
52
+ :no_devices_found, 3,
53
+ :invalid_device, 4,
54
+ :memory_error, 5,
55
+ :invalid_parameter, 6,
56
+ :invalid_use, 7,
57
+ :driver_error, 8,
58
+ :system_error, 9,
59
+ :thread_error, 10
60
+ ]
61
+
62
+ callback :rtmidi_c_callback, [:double, :pointer, :size_t, :pointer], :void
63
+ callback :rtmidi_error_c_callback, [:rtmidi_error_type, :string, :pointer], :void
64
+
65
+ FUNCTION_SIGNATURES = {
66
+ rtmidi_get_version: [[], :string],
67
+ rtmidi_get_compiled_api: [[:pointer, :uint], :int],
68
+ rtmidi_api_name: [[:rtmidi_api], :string],
69
+ rtmidi_api_display_name: [[:rtmidi_api], :string],
70
+ rtmidi_compiled_api_by_name: [[:string], :rtmidi_api],
71
+ rtmidi_open_port: [[:pointer, :uint, :string], :void],
72
+ rtmidi_open_virtual_port: [[:pointer, :string], :void],
73
+ rtmidi_close_port: [[:pointer], :void],
74
+ rtmidi_get_port_count: [[:pointer], :uint],
75
+ rtmidi_get_port_name: [[:pointer, :uint, :pointer, :pointer], :int],
76
+ rtmidi_set_error_callback: [[:pointer, :rtmidi_error_c_callback, :pointer], :void],
77
+ rtmidi_in_create_default: [[], :pointer],
78
+ rtmidi_in_create: [[:rtmidi_api, :string, :uint], :pointer],
79
+ rtmidi_in_free: [[:pointer], :void],
80
+ rtmidi_in_get_current_api: [[:pointer], :rtmidi_api],
81
+ rtmidi_in_set_callback: [[:pointer, :rtmidi_c_callback, :pointer], :void],
82
+ rtmidi_in_cancel_callback: [[:pointer], :void],
83
+ rtmidi_in_ignore_types: [[:pointer, :bool, :bool, :bool], :void],
84
+ rtmidi_in_get_message: [[:pointer, :pointer, :pointer], :double],
85
+ rtmidi_out_create_default: [[], :pointer],
86
+ rtmidi_out_create: [[:rtmidi_api, :string], :pointer],
87
+ rtmidi_out_free: [[:pointer], :void],
88
+ rtmidi_out_get_current_api: [[:pointer], :rtmidi_api],
89
+ rtmidi_out_send_message: [[:pointer, :pointer, :int], :int]
90
+ }.freeze
91
+
92
+ OPTIONAL_FUNCTIONS = [
93
+ :rtmidi_set_error_callback
94
+ ].freeze
95
+
96
+ @loaded = false
97
+ @load_error = nil
98
+ @available_functions = [].freeze
99
+
100
+ class << self
101
+ def loaded?
102
+ @loaded
103
+ end
104
+
105
+ def ensure_loaded!
106
+ return if loaded?
107
+
108
+ raise @load_error || LoadError, LOAD_ERROR_MESSAGE
109
+ end
110
+
111
+ def read_size_t(pointer)
112
+ if FFI::Platform::ADDRESS_SIZE == 64
113
+ pointer.get_uint64(0)
114
+ else
115
+ pointer.get_uint32(0)
116
+ end
117
+ end
118
+
119
+ def write_size_t(pointer, value)
120
+ if FFI::Platform::ADDRESS_SIZE == 64
121
+ pointer.put_uint64(0, value)
122
+ else
123
+ pointer.put_uint32(0, value)
124
+ end
125
+ end
126
+
127
+ def check_error(device_ptr)
128
+ ensure_loaded!
129
+ return if device_ptr.nil? || device_ptr.null?
130
+
131
+ wrapper = Wrapper.new(device_ptr)
132
+ return if wrapper[:ok]
133
+
134
+ msg_ptr = wrapper[:msg]
135
+ message = msg_ptr.nil? || msg_ptr.null? ? "Unknown RtMidi error" : msg_ptr.read_string
136
+ raise Rtmidi::Error, message
137
+ end
138
+
139
+ def function_available?(name)
140
+ @available_functions.include?(name.to_sym)
141
+ end
142
+
143
+ private
144
+
145
+ def library_candidates
146
+ candidates = []
147
+ lib_path = ENV["RTMIDI_LIB_PATH"]
148
+ candidates << lib_path unless lib_path.nil? || lib_path.empty?
149
+ candidates.concat(DEFAULT_LIBRARIES)
150
+ candidates.uniq
151
+ end
152
+
153
+ def load_error_with_original(error)
154
+ LoadError.new("#{LOAD_ERROR_MESSAGE}\nOriginal error: #{error.message}")
155
+ end
156
+ end
157
+
158
+ begin
159
+ ffi_lib(library_candidates)
160
+ @loaded = true
161
+ rescue LoadError => e
162
+ @loaded = false
163
+ @load_error = load_error_with_original(e)
164
+ end
165
+
166
+ if @loaded
167
+ begin
168
+ available_functions = []
169
+
170
+ FUNCTION_SIGNATURES.each do |name, signature|
171
+ attach_function(name, signature[0], signature[1])
172
+ available_functions << name
173
+ rescue FFI::NotFoundError => e
174
+ raise e unless OPTIONAL_FUNCTIONS.include?(name)
175
+ end
176
+
177
+ @available_functions = available_functions.freeze
178
+ (OPTIONAL_FUNCTIONS - available_functions).each do |name|
179
+ define_singleton_method(name) do |*_args|
180
+ raise NotImplementedError, "RtMidi function #{name} is not available in the loaded librtmidi"
181
+ end
182
+ end
183
+ rescue FFI::NotFoundError => e
184
+ @loaded = false
185
+ @available_functions = [].freeze
186
+ @load_error = load_error_with_original(e)
187
+ end
188
+ end
189
+
190
+ unless @loaded
191
+ FUNCTION_SIGNATURES.each_key do |name|
192
+ define_singleton_method(name) do |*_args|
193
+ ensure_loaded!
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rtmidi
4
+ VERSION = "0.1.0"
5
+ end
data/lib/rtmidi.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rtmidi/version"
4
+ require_relative "rtmidi/error"
5
+ require_relative "rtmidi/message"
6
+ require_relative "rtmidi/native"
7
+ require_relative "rtmidi/api"
8
+ require_relative "rtmidi/midi_out"
9
+ require_relative "rtmidi/midi_in"
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rtmidi-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ffi
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.15'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.15'
26
+ description: Complete Ruby bindings for RtMidi C API using FFI, including Ruby-idiomatic
27
+ MidiIn/MidiOut wrappers for realtime MIDI on macOS, Linux, and Windows.
28
+ email:
29
+ - t.yudai92@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - CHANGELOG.md
35
+ - LICENSE.txt
36
+ - README.md
37
+ - Rakefile
38
+ - examples/list_ports.rb
39
+ - examples/receive_callback.rb
40
+ - examples/receive_polling.rb
41
+ - examples/send_note.rb
42
+ - examples/sysex_send.rb
43
+ - examples/virtual_port.rb
44
+ - lib/rtmidi.rb
45
+ - lib/rtmidi/api.rb
46
+ - lib/rtmidi/error.rb
47
+ - lib/rtmidi/message.rb
48
+ - lib/rtmidi/midi_in.rb
49
+ - lib/rtmidi/midi_out.rb
50
+ - lib/rtmidi/native.rb
51
+ - lib/rtmidi/version.rb
52
+ homepage: https://github.com/ydah/mtmidi
53
+ licenses:
54
+ - MIT
55
+ metadata:
56
+ source_code_uri: https://github.com/ydah/mtmidi
57
+ changelog_uri: https://github.com/ydah/mtmidi/blob/main/CHANGELOG.md
58
+ documentation_uri: https://rubydoc.info/gems/rtmidi-ruby
59
+ rubygems_mfa_required: 'true'
60
+ rdoc_options: []
61
+ require_paths:
62
+ - lib
63
+ required_ruby_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 3.1.0
68
+ required_rubygems_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ requirements: []
74
+ rubygems_version: 4.0.6
75
+ specification_version: 4
76
+ summary: Ruby FFI bindings for RtMidi with high-level MIDI I/O API
77
+ test_files: []