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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a7893d826f1f417ce7a5b056e0a008ac45f3ff3f42fb1efab5444df197925433
4
+ data.tar.gz: 436013485d4e4faf7f33eaddf01b42422abba0ede737a6a2df49c9969eeed67a
5
+ SHA512:
6
+ metadata.gz: 5ab907249073716156bfc7638d69c1967f5c7fb2a21eb5fbd8545317d3964862bb71792c4354198373ea94e08b3c1152bff3ecec62a2f3580299831d178e7944
7
+ data.tar.gz: 94cb6a7900b92b1efe38ada1f0d717b55273b2be13497c8f55e644ba5c6dd8aa37b2525a559a49d84c566c903d83d8c3026e762d57ac9e5baa8aed493f5037cb
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.1.0] - 2026-03-07
8
+
9
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,327 @@
1
+ # rtmidi-ruby
2
+
3
+ `rtmidi-ruby` is a Ruby FFI binding for the RtMidi C API (`rtmidi_c.h`).
4
+ It exposes the low-level C bindings through `Rtmidi::Native` and a higher-level
5
+ Ruby API through `Rtmidi::MidiIn`, `Rtmidi::MidiOut`, and `Rtmidi::Message`.
6
+
7
+ ## Features
8
+
9
+ - `Rtmidi.version`, `Rtmidi.compiled_apis`, and API name helpers
10
+ - low-level access to the RtMidi C API via `Rtmidi::Native`
11
+ - `Rtmidi::MidiIn` with callback and polling based input
12
+ - `Rtmidi::MidiOut` helpers for channel, system common, and realtime messages
13
+ - typed MIDI messages through `Rtmidi::Message`
14
+ - virtual port support
15
+
16
+ ## Requirements
17
+
18
+ - Ruby 3.1+
19
+ - a system `librtmidi` that provides the RtMidi C API
20
+
21
+ Install `librtmidi` with your package manager:
22
+
23
+ - macOS: `brew install rtmidi`
24
+ - Ubuntu/Debian: `sudo apt install librtmidi-dev`
25
+ - Fedora: `sudo dnf install rtmidi-devel`
26
+ - Arch: `sudo pacman -S rtmidi`
27
+ - Windows: install an RtMidi DLL and make sure it is on `PATH`
28
+
29
+ If the library is installed in a non-standard location, set `RTMIDI_LIB_PATH`.
30
+
31
+ ```bash
32
+ RTMIDI_LIB_PATH=/path/to/librtmidi.dylib bundle exec ruby your_script.rb
33
+ ```
34
+
35
+ Some `librtmidi` builds do not expose `rtmidi_set_error_callback`. In that case,
36
+ normal MIDI I/O still works, but `on_error` callbacks are unavailable.
37
+
38
+ ## Installation
39
+
40
+ Add the gem to your Gemfile:
41
+
42
+ ```ruby
43
+ gem "rtmidi-ruby"
44
+ ```
45
+
46
+ Then install dependencies:
47
+
48
+ ```bash
49
+ bundle install
50
+ ```
51
+
52
+ Or install the gem directly:
53
+
54
+ ```bash
55
+ gem install rtmidi-ruby
56
+ ```
57
+
58
+ ## Quick Start
59
+
60
+ ```ruby
61
+ require "rtmidi"
62
+
63
+ puts "RtMidi version: #{Rtmidi.version}"
64
+ puts "Compiled APIs: #{Rtmidi.compiled_apis.inspect}"
65
+ ```
66
+
67
+ ### List Ports
68
+
69
+ ```ruby
70
+ require "rtmidi"
71
+
72
+ out = Rtmidi::MidiOut.new
73
+ puts "Output ports:"
74
+ out.port_names.each_with_index { |name, index| puts " #{index}: #{name}" }
75
+ out.close
76
+
77
+ input = Rtmidi::MidiIn.new
78
+ puts "Input ports:"
79
+ input.port_names.each_with_index { |name, index| puts " #{index}: #{name}" }
80
+ input.close
81
+ ```
82
+
83
+ ### Send Note On/Off
84
+
85
+ ```ruby
86
+ require "rtmidi"
87
+
88
+ out = Rtmidi::MidiOut.new
89
+
90
+ if out.port_count.zero?
91
+ warn "No output ports available."
92
+ else
93
+ begin
94
+ out.open_port(0)
95
+ out.note_on(0, 60, 100)
96
+ sleep 0.5
97
+ out.note_off(0, 60)
98
+ ensure
99
+ out.close
100
+ end
101
+ end
102
+ ```
103
+
104
+ ### Receive With Callback
105
+
106
+ ```ruby
107
+ require "rtmidi"
108
+
109
+ midi_in = Rtmidi::MidiIn.new
110
+
111
+ if midi_in.port_count.zero?
112
+ warn "No input ports available."
113
+ midi_in.close
114
+ exit 0
115
+ end
116
+
117
+ begin
118
+ midi_in.ignore_types(sysex: false, timing: false, active_sensing: false)
119
+ midi_in.open_port(0)
120
+
121
+ midi_in.on_message do |message, timestamp|
122
+ puts "#{timestamp}: #{message.map { |byte| format('%02X', byte) }.join(' ')}"
123
+ end
124
+
125
+ puts "Listening... (Ctrl-C to quit)"
126
+ sleep
127
+ ensure
128
+ midi_in&.close
129
+ end
130
+ ```
131
+
132
+ ### Receive With Polling
133
+
134
+ ```ruby
135
+ require "rtmidi"
136
+
137
+ midi_in = Rtmidi::MidiIn.new
138
+
139
+ if midi_in.port_count.zero?
140
+ warn "No input ports available."
141
+ midi_in.close
142
+ exit 0
143
+ end
144
+
145
+ begin
146
+ midi_in.open_port(0)
147
+
148
+ loop do
149
+ packet = midi_in.get_message
150
+ next if packet.nil?
151
+
152
+ message, timestamp = packet
153
+ puts "#{timestamp}: #{message.inspect}"
154
+
155
+ sleep 0.001
156
+ end
157
+ ensure
158
+ midi_in&.close
159
+ end
160
+ ```
161
+
162
+ ### Typed Messages
163
+
164
+ `Rtmidi::Message` can parse raw bytes into typed structs and `MidiOut#send_message`
165
+ accepts either raw byte arrays or typed messages.
166
+
167
+ ```ruby
168
+ require "rtmidi"
169
+
170
+ message = Rtmidi::Message.parse([0x92, 64, 96])
171
+ p message
172
+ # => #<struct Rtmidi::Message::NoteOn channel=2, note=64, velocity=96>
173
+
174
+ out = Rtmidi::MidiOut.new
175
+
176
+ if out.port_count.zero?
177
+ warn "No output ports available."
178
+ else
179
+ begin
180
+ out.open_port(0)
181
+ out.send_message(Rtmidi::Message::ProgramChange.new(channel: 1, program: 10))
182
+ ensure
183
+ out.close
184
+ end
185
+ end
186
+ ```
187
+
188
+ For typed input callbacks:
189
+
190
+ ```ruby
191
+ midi_in.on_message(parsed: true) do |message, timestamp|
192
+ p [message.class, message, timestamp]
193
+ end
194
+ ```
195
+
196
+ ### System/Common and Realtime Helpers
197
+
198
+ `Rtmidi::MidiOut` includes helpers for common output messages:
199
+
200
+ - `sysex`
201
+ - `control_change`
202
+ - `program_change`
203
+ - `pitch_bend`
204
+ - `channel_aftertouch`
205
+ - `poly_aftertouch`
206
+ - `time_code_quarter_frame`
207
+ - `song_position_pointer`
208
+ - `song_select`
209
+ - `tune_request`
210
+ - `timing_clock`
211
+ - `start`
212
+ - `continue`
213
+ - `stop`
214
+ - `active_sensing`
215
+ - `system_reset`
216
+ - `nrpn`
217
+
218
+ Example:
219
+
220
+ ```ruby
221
+ require "rtmidi"
222
+
223
+ out = Rtmidi::MidiOut.new
224
+
225
+ if out.port_count.zero?
226
+ warn "No output ports available."
227
+ else
228
+ begin
229
+ out.open_port(0)
230
+ out.sysex([0x7D, 0x01])
231
+ out.song_select(3)
232
+ out.start
233
+ out.nrpn(0, 0x1234, 0x0567)
234
+ ensure
235
+ out.close
236
+ end
237
+ end
238
+ ```
239
+
240
+ ### Virtual Ports
241
+
242
+ ```ruby
243
+ require "rtmidi"
244
+
245
+ midi_in = Rtmidi::MidiIn.new
246
+ midi_out = Rtmidi::MidiOut.new
247
+
248
+ begin
249
+ midi_in.open_virtual_port(name: "Rtmidi Ruby Virtual In")
250
+ midi_out.open_virtual_port(name: "Rtmidi Ruby Virtual Out")
251
+
252
+ puts "Virtual ports opened."
253
+ sleep
254
+ ensure
255
+ midi_out&.close
256
+ midi_in&.close
257
+ end
258
+ ```
259
+
260
+ ### Low-Level C API
261
+
262
+ ```ruby
263
+ require "rtmidi"
264
+
265
+ handle = nil
266
+
267
+ begin
268
+ handle = Rtmidi::Native.rtmidi_out_create_default
269
+ Rtmidi::Native.check_error(handle)
270
+
271
+ count = Rtmidi::Native.rtmidi_get_port_count(handle)
272
+ Rtmidi::Native.check_error(handle)
273
+
274
+ puts "#{count} output ports found"
275
+ ensure
276
+ Rtmidi::Native.rtmidi_out_free(handle) if handle && !handle.null?
277
+ end
278
+ ```
279
+
280
+ ## Error Handling
281
+
282
+ Synchronous operations raise `Rtmidi::Error` subclasses where possible, including:
283
+
284
+ - `Rtmidi::NoDevicesError`
285
+ - `Rtmidi::InvalidPortError`
286
+ - `Rtmidi::InvalidUseError`
287
+ - `Rtmidi::DriverError`
288
+
289
+ Validation failures use `ArgumentError`.
290
+
291
+ If a callback raises, the exception is stored and surfaced on the next locked API call.
292
+
293
+ ## Callback Notes
294
+
295
+ - Keep callback processing lightweight.
296
+ - For heavier work, pass the event to a `Queue` and handle it in another thread.
297
+ - Do not call `close` or `close_port` from inside an input callback.
298
+ - `parsed: true` yields `Rtmidi::Message::*` structs instead of raw byte arrays.
299
+
300
+ ## Examples
301
+
302
+ The repository includes runnable examples in [`examples/`](examples):
303
+
304
+ - `examples/list_ports.rb`
305
+ - `examples/send_note.rb`
306
+ - `examples/sysex_send.rb`
307
+ - `examples/receive_callback.rb`
308
+ - `examples/receive_polling.rb`
309
+ - `examples/virtual_port.rb`
310
+
311
+ Run them against the local checkout with:
312
+
313
+ ```bash
314
+ bundle exec ruby -Ilib examples/list_ports.rb
315
+ ```
316
+
317
+ ## Development
318
+
319
+ ```bash
320
+ bundle install
321
+ bundle exec rspec
322
+ bundle exec rake
323
+ ```
324
+
325
+ ## License
326
+
327
+ Released under the MIT License. See `LICENSE.txt`.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rtmidi"
4
+
5
+ puts "RtMidi version: #{Rtmidi.version}"
6
+ puts "Compiled APIs: #{Rtmidi.compiled_apis.inspect}"
7
+
8
+ out = Rtmidi::MidiOut.new
9
+ puts "Output ports:"
10
+ out.port_names.each_with_index { |name, index| puts " #{index}: #{name}" }
11
+ out.close
12
+
13
+ input = Rtmidi::MidiIn.new
14
+ puts "Input ports:"
15
+ input.port_names.each_with_index { |name, index| puts " #{index}: #{name}" }
16
+ input.close
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rtmidi"
4
+
5
+ midi_in = Rtmidi::MidiIn.new
6
+
7
+ if midi_in.port_count.zero?
8
+ warn "No input ports available."
9
+ midi_in.close
10
+ exit 0
11
+ end
12
+
13
+ begin
14
+ midi_in.ignore_types(sysex: false, timing: false, active_sensing: false)
15
+ midi_in.open_port(0)
16
+
17
+ midi_in.on_message do |message, timestamp|
18
+ puts "#{timestamp}: #{message.map { |b| format('%02X', b) }.join(' ')}"
19
+ end
20
+
21
+ puts "Listening... (Ctrl-C to quit)"
22
+ sleep
23
+ ensure
24
+ midi_in&.close
25
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rtmidi"
4
+
5
+ midi_in = Rtmidi::MidiIn.new
6
+
7
+ if midi_in.port_count.zero?
8
+ warn "No input ports available."
9
+ midi_in.close
10
+ exit 0
11
+ end
12
+
13
+ begin
14
+ midi_in.open_port(0)
15
+
16
+ loop do
17
+ packet = midi_in.get_message
18
+ if packet
19
+ message, timestamp = packet
20
+ puts "#{timestamp}: #{message.inspect}"
21
+ end
22
+
23
+ sleep 0.001
24
+ end
25
+ ensure
26
+ midi_in&.close
27
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rtmidi"
4
+
5
+ out = Rtmidi::MidiOut.new
6
+
7
+ if out.port_count.zero?
8
+ warn "No output ports available."
9
+ else
10
+ begin
11
+ out.open_port(0)
12
+ out.note_on(0, 60, 100)
13
+ sleep 0.5
14
+ out.note_off(0, 60)
15
+ ensure
16
+ out.close
17
+ end
18
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rtmidi"
4
+
5
+ # Example SysEx payload (manufacturer-specific example):
6
+ # F0 7D 10 01 F7
7
+ message = [0xF0, 0x7D, 0x10, 0x01, 0xF7]
8
+
9
+ out = Rtmidi::MidiOut.new
10
+
11
+ if out.port_count.zero?
12
+ warn "No output ports available."
13
+ else
14
+ begin
15
+ out.open_port(0)
16
+ out.send_message(message)
17
+ ensure
18
+ out.close
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rtmidi"
4
+
5
+ midi_in = Rtmidi::MidiIn.new
6
+ midi_out = Rtmidi::MidiOut.new
7
+
8
+ begin
9
+ midi_in.open_virtual_port(name: "Rtmidi Ruby Virtual In")
10
+ midi_out.open_virtual_port(name: "Rtmidi Ruby Virtual Out")
11
+
12
+ puts "Virtual ports opened."
13
+ puts "Connect them from your MIDI patchbay / DAW, then send/receive as needed."
14
+
15
+ sleep
16
+ ensure
17
+ midi_out&.close
18
+ midi_in&.close
19
+ end
data/lib/rtmidi/api.rb ADDED
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rtmidi
4
+ module Api
5
+ VALUES = {
6
+ unspecified: 0,
7
+ macosx_core: 1,
8
+ linux_alsa: 2,
9
+ unix_jack: 3,
10
+ windows_mm: 4,
11
+ rtmidi_dummy: 5,
12
+ web_midi_api: 6,
13
+ windows_uwp: 7,
14
+ android_amidi: 8,
15
+ num_apis: 9
16
+ }.freeze
17
+
18
+ NUMBERS = VALUES.invert.freeze
19
+
20
+ class << self
21
+ def normalize(api)
22
+ case api
23
+ when Symbol
24
+ symbol_for(api)
25
+ when Integer
26
+ symbol_for(api)
27
+ else
28
+ nil
29
+ end || raise(ArgumentError, "unknown API: #{api.inspect}")
30
+ end
31
+
32
+ def symbol_for(api)
33
+ return api if api.is_a?(Symbol) && VALUES.key?(api)
34
+ return NUMBERS[api] if api.is_a?(Integer)
35
+
36
+ nil
37
+ end
38
+ end
39
+ end
40
+
41
+ class << self
42
+ def version
43
+ Native.rtmidi_get_version
44
+ end
45
+
46
+ def compiled_apis
47
+ Native.ensure_loaded!
48
+
49
+ count = Native.rtmidi_get_compiled_api(nil, 0).to_i
50
+ return [] if count <= 0
51
+
52
+ buffer = FFI::MemoryPointer.new(:int, count)
53
+ written = Native.rtmidi_get_compiled_api(buffer, count).to_i
54
+ length = [written, count].min
55
+ return [] if length <= 0
56
+
57
+ buffer.read_array_of_int(length).filter_map { |value| Api.symbol_for(value) }
58
+ end
59
+
60
+ def api_name(api)
61
+ Native.rtmidi_api_name(Api.normalize(api))
62
+ end
63
+
64
+ def api_display_name(api)
65
+ Native.rtmidi_api_display_name(Api.normalize(api))
66
+ end
67
+
68
+ def api_by_name(name)
69
+ value = Native.rtmidi_compiled_api_by_name(name.to_s)
70
+ Api.symbol_for(value) || :unspecified
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rtmidi
4
+ class Error < StandardError
5
+ attr_reader :type
6
+
7
+ def initialize(message = "RtMidi error", type: :unspecified)
8
+ @type = type.to_sym
9
+ super(message)
10
+ end
11
+ end
12
+
13
+ class NoDevicesError < Error; end
14
+ class InvalidPortError < Error; end
15
+ class DriverError < Error; end
16
+ class MemoryError < Error; end
17
+ class InvalidUseError < Error; end
18
+
19
+ ERROR_MAP = {
20
+ no_devices_found: NoDevicesError,
21
+ invalid_device: InvalidPortError,
22
+ memory_error: MemoryError,
23
+ invalid_parameter: ArgumentError,
24
+ invalid_use: InvalidUseError,
25
+ driver_error: DriverError,
26
+ system_error: Error,
27
+ thread_error: Error,
28
+ warning: nil,
29
+ debug_warning: nil,
30
+ unspecified: Error
31
+ }.freeze
32
+
33
+ WARNING_TYPES = %i[warning debug_warning].freeze
34
+
35
+ class << self
36
+ def warning_type?(type)
37
+ WARNING_TYPES.include?(type.to_sym)
38
+ end
39
+
40
+ def error_class_for(type)
41
+ ERROR_MAP.fetch(type.to_sym, Error)
42
+ end
43
+
44
+ def error_for(type, message)
45
+ klass = error_class_for(type)
46
+ return Error.new(message, type: type) if klass.nil?
47
+ return klass.new(message) unless klass <= Error
48
+
49
+ klass.new(message, type: type)
50
+ end
51
+ end
52
+ end