webmidi 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.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +73 -0
  5. data/Rakefile +48 -0
  6. data/lib/webmidi/access.rb +170 -0
  7. data/lib/webmidi/callback_subscription.rb +26 -0
  8. data/lib/webmidi/clock.rb +129 -0
  9. data/lib/webmidi/configuration.rb +43 -0
  10. data/lib/webmidi/error.rb +26 -0
  11. data/lib/webmidi/message/base.rb +64 -0
  12. data/lib/webmidi/message/channel.rb +238 -0
  13. data/lib/webmidi/message/parser.rb +308 -0
  14. data/lib/webmidi/message/system.rb +162 -0
  15. data/lib/webmidi/message/ump.rb +675 -0
  16. data/lib/webmidi/message.rb +154 -0
  17. data/lib/webmidi/middleware/base.rb +16 -0
  18. data/lib/webmidi/middleware/channel_map.rb +36 -0
  19. data/lib/webmidi/middleware/filter.rb +22 -0
  20. data/lib/webmidi/middleware/logger.rb +17 -0
  21. data/lib/webmidi/middleware/note_range_filter.rb +34 -0
  22. data/lib/webmidi/middleware/panic.rb +73 -0
  23. data/lib/webmidi/middleware/pipeline.rb +19 -0
  24. data/lib/webmidi/middleware/recorder.rb +123 -0
  25. data/lib/webmidi/middleware/split_by_channel.rb +66 -0
  26. data/lib/webmidi/middleware/stack.rb +55 -0
  27. data/lib/webmidi/middleware/timing_gate.rb +58 -0
  28. data/lib/webmidi/middleware/transpose.rb +30 -0
  29. data/lib/webmidi/middleware/velocity_clamp.rb +37 -0
  30. data/lib/webmidi/middleware/velocity_scale.rb +55 -0
  31. data/lib/webmidi/middleware.rb +21 -0
  32. data/lib/webmidi/music/chord.rb +90 -0
  33. data/lib/webmidi/music/note.rb +102 -0
  34. data/lib/webmidi/music/rhythm.rb +92 -0
  35. data/lib/webmidi/music/scale.rb +85 -0
  36. data/lib/webmidi/music.rb +24 -0
  37. data/lib/webmidi/network/apple_midi.rb +189 -0
  38. data/lib/webmidi/network/osc.rb +205 -0
  39. data/lib/webmidi/network/rtp.rb +410 -0
  40. data/lib/webmidi/network.rb +10 -0
  41. data/lib/webmidi/port/base.rb +89 -0
  42. data/lib/webmidi/port/input.rb +158 -0
  43. data/lib/webmidi/port/map.rb +65 -0
  44. data/lib/webmidi/port/output.rb +208 -0
  45. data/lib/webmidi/port.rb +11 -0
  46. data/lib/webmidi/smf/event.rb +206 -0
  47. data/lib/webmidi/smf/reader.rb +237 -0
  48. data/lib/webmidi/smf/sequence.rb +135 -0
  49. data/lib/webmidi/smf/tempo_map.rb +107 -0
  50. data/lib/webmidi/smf/track.rb +130 -0
  51. data/lib/webmidi/smf/writer.rb +121 -0
  52. data/lib/webmidi/smf.rb +13 -0
  53. data/lib/webmidi/transport/adapter.rb +46 -0
  54. data/lib/webmidi/transport/base.rb +59 -0
  55. data/lib/webmidi/transport/device_info.rb +7 -0
  56. data/lib/webmidi/transport/null.rb +81 -0
  57. data/lib/webmidi/transport/virtual.rb +184 -0
  58. data/lib/webmidi/transport.rb +80 -0
  59. data/lib/webmidi/version.rb +5 -0
  60. data/lib/webmidi/virtual/loopback.rb +45 -0
  61. data/lib/webmidi/virtual/port.rb +48 -0
  62. data/lib/webmidi/virtual.rb +9 -0
  63. data/lib/webmidi.rb +19 -0
  64. data/webmidi.gemspec +32 -0
  65. metadata +108 -0
@@ -0,0 +1,410 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "securerandom"
5
+ require_relative "../callback_subscription"
6
+
7
+ module Webmidi
8
+ module Network
9
+ module RTP
10
+ PROTOCOL_VERSION = 2
11
+ MIDI_PAYLOAD_TYPE = 97
12
+
13
+ class ControlPacket
14
+ SIGNATURE = 0xFFFF
15
+ PROTOCOL_VERSION = 2
16
+ COMMANDS = {
17
+ invitation: "IN",
18
+ accepted: "OK",
19
+ rejected: "NO",
20
+ synchronization: "CK",
21
+ receiver_feedback: "RS",
22
+ end_session: "BY"
23
+ }.freeze
24
+ COMMAND_BY_CODE = COMMANDS.invert.freeze
25
+
26
+ attr_reader :command, :version, :token, :ssrc, :name, :count, :timestamps, :sequence_number
27
+
28
+ def initialize(command:, version: PROTOCOL_VERSION, token: 0, ssrc: 0, name: "", count: 0,
29
+ timestamps: [], sequence_number: 0)
30
+ raise InvalidMessageError, "Unknown AppleMIDI command: #{command.inspect}" unless COMMANDS.key?(command)
31
+ self.class.validate_range!(version, "Protocol version", 0, 0xFFFF_FFFF)
32
+ self.class.validate_range!(token, "Initiator token", 0, 0xFFFF_FFFF)
33
+ self.class.validate_range!(ssrc, "SSRC", 0, 0xFFFF_FFFF)
34
+ self.class.validate_range!(count, "Synchronization count", 0, 3)
35
+ self.class.validate_range!(sequence_number, "Sequence number", 0, 0xFFFF)
36
+
37
+ @command = command
38
+ @version = version
39
+ @token = token
40
+ @ssrc = ssrc
41
+ @name = name.to_s
42
+ @count = count
43
+ @timestamps = timestamps.dup.freeze
44
+ @sequence_number = sequence_number
45
+ end
46
+
47
+ def self.invitation(token:, ssrc:, name:, version: PROTOCOL_VERSION)
48
+ new(command: :invitation, version: version, token: token, ssrc: ssrc, name: name)
49
+ end
50
+
51
+ def self.accepted(token:, ssrc:, name:, version: PROTOCOL_VERSION)
52
+ new(command: :accepted, version: version, token: token, ssrc: ssrc, name: name)
53
+ end
54
+
55
+ def self.rejected(token:, ssrc:, name:, version: PROTOCOL_VERSION)
56
+ new(command: :rejected, version: version, token: token, ssrc: ssrc, name: name)
57
+ end
58
+
59
+ def self.synchronization(ssrc:, count:, timestamps:)
60
+ new(command: :synchronization, ssrc: ssrc, count: count, timestamps: timestamps)
61
+ end
62
+
63
+ def self.receiver_feedback(ssrc:, sequence_number:)
64
+ new(command: :receiver_feedback, ssrc: ssrc, sequence_number: sequence_number)
65
+ end
66
+
67
+ def self.end_session(ssrc:)
68
+ new(command: :end_session, ssrc: ssrc)
69
+ end
70
+
71
+ def to_bytes
72
+ header = [SIGNATURE].pack("n") + COMMANDS.fetch(@command)
73
+ header + payload_bytes
74
+ end
75
+
76
+ def self.parse(bytes)
77
+ return nil if bytes.bytesize < 4
78
+
79
+ signature = bytes[0, 2].unpack1("n")
80
+ command = COMMAND_BY_CODE[bytes[2, 2]]
81
+ return nil unless signature == SIGNATURE && command
82
+
83
+ parse_payload(command, bytes[4..] || "")
84
+ end
85
+
86
+ def self.parse_payload(command, payload)
87
+ case command
88
+ when :invitation, :accepted, :rejected
89
+ return nil if payload.bytesize < 12
90
+
91
+ version, token, ssrc = payload[0, 12].unpack("NNN")
92
+ name = (payload[12..] || "").split("\0", 2).first
93
+ new(command: command, version: version, token: token, ssrc: ssrc, name: name)
94
+ when :synchronization
95
+ return nil if payload.bytesize < 28
96
+
97
+ ssrc = payload[0, 4].unpack1("N")
98
+ count = payload.getbyte(4)
99
+ timestamps = payload[8, 24].unpack("Q>Q>Q>")
100
+ new(command: command, ssrc: ssrc, count: count, timestamps: timestamps)
101
+ when :receiver_feedback
102
+ return nil if payload.bytesize < 6
103
+
104
+ ssrc, sequence_number = payload[0, 6].unpack("Nn")
105
+ new(command: command, ssrc: ssrc, sequence_number: sequence_number)
106
+ when :end_session
107
+ return nil if payload.bytesize < 4
108
+
109
+ new(command: command, ssrc: payload[0, 4].unpack1("N"))
110
+ end
111
+ end
112
+
113
+ def self.timestamp
114
+ (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1_000_000).to_i
115
+ end
116
+
117
+ def self.validate_range!(value, name, min, max)
118
+ return if value.is_a?(Integer) && value.between?(min, max)
119
+
120
+ raise InvalidMessageError, "#{name} must be between #{min} and #{max}, got #{value.inspect}"
121
+ end
122
+
123
+ private
124
+
125
+ def payload_bytes
126
+ case @command
127
+ when :invitation, :accepted, :rejected
128
+ [@version, @token, @ssrc].pack("NNN") + "#{@name}\0".b
129
+ when :synchronization
130
+ padded = @timestamps.first(3)
131
+ padded << 0 while padded.size < 3
132
+ [@ssrc, @count].pack("NC") + "\0\0\0" + padded.pack("Q>Q>Q>")
133
+ when :receiver_feedback
134
+ [@ssrc, @sequence_number].pack("Nn")
135
+ when :end_session
136
+ [@ssrc].pack("N")
137
+ end
138
+ end
139
+ end
140
+
141
+ module_function
142
+
143
+ def server(port: 5004, name: "Webmidi")
144
+ Session.new(port: port, name: name, mode: :server)
145
+ end
146
+
147
+ def connect(host, port: 5004)
148
+ session = Session.new(port: 0, name: "Webmidi Client", mode: :client)
149
+ session.connect_to(host, port)
150
+ session
151
+ end
152
+
153
+ class Packet
154
+ attr_reader :sequence_number, :timestamp, :ssrc, :midi_data
155
+
156
+ def initialize(sequence_number:, timestamp:, ssrc:, midi_data:)
157
+ self.class.validate_range!(sequence_number, "Sequence number", 0, 0xFFFF)
158
+ self.class.validate_range!(timestamp, "Timestamp", 0, 0xFFFF_FFFF)
159
+ self.class.validate_range!(ssrc, "SSRC", 0, 0xFFFF_FFFF)
160
+ self.class.validate_midi_data!(midi_data)
161
+ @sequence_number = sequence_number
162
+ @timestamp = timestamp
163
+ @ssrc = ssrc
164
+ @midi_data = midi_data.dup.freeze
165
+ end
166
+
167
+ def to_bytes
168
+ midi_bytes = @midi_data.flatten
169
+ header = [
170
+ (PROTOCOL_VERSION << 6) | 0x00,
171
+ MIDI_PAYLOAD_TYPE,
172
+ @sequence_number & 0xFFFF
173
+ ].pack("CCn")
174
+
175
+ header += [@timestamp, @ssrc].pack("NN")
176
+
177
+ header += [midi_bytes.size].pack("n")
178
+ header += midi_bytes.pack("C*")
179
+
180
+ header
181
+ end
182
+
183
+ def self.parse(bytes)
184
+ return nil if bytes.bytesize < 14
185
+
186
+ flags, payload_type, seq = bytes[0, 4].unpack("CCn")
187
+ return nil unless (flags >> 6) == PROTOCOL_VERSION
188
+ return nil unless payload_type == MIDI_PAYLOAD_TYPE
189
+
190
+ timestamp, ssrc = bytes[4, 8].unpack("NN")
191
+ midi_length = bytes[12, 2].unpack1("n")
192
+ return nil unless bytes.bytesize == 14 + midi_length
193
+
194
+ midi_data = bytes[14, midi_length].bytes
195
+
196
+ new(
197
+ sequence_number: seq,
198
+ timestamp: timestamp,
199
+ ssrc: ssrc,
200
+ midi_data: midi_data
201
+ )
202
+ end
203
+
204
+ def self.validate_range!(value, name, min, max)
205
+ return if value.is_a?(Integer) && value.between?(min, max)
206
+
207
+ raise InvalidMessageError, "#{name} must be between #{min} and #{max}, got #{value.inspect}"
208
+ end
209
+
210
+ def self.validate_midi_data!(midi_data)
211
+ unless midi_data.respond_to?(:each)
212
+ raise InvalidMessageError, "MIDI data must be enumerable, got #{midi_data.class}"
213
+ end
214
+
215
+ midi_data.each_with_index do |byte, index|
216
+ next if byte.is_a?(Integer) && byte.between?(0, 255)
217
+
218
+ raise InvalidMessageError, "MIDI data byte #{index} must be between 0 and 255, got #{byte.inspect}"
219
+ end
220
+ end
221
+ end
222
+
223
+ class Session
224
+ attr_reader :name, :ssrc
225
+
226
+ def initialize(port:, name:, mode: :server, ssrc: SecureRandom.random_number(0xFFFFFFFF))
227
+ @port = port
228
+ @name = name
229
+ @mode = mode
230
+ @ssrc = ssrc
231
+ @sequence_number = 0
232
+ @peers = []
233
+ @callbacks = []
234
+ @control_callbacks = []
235
+ @error_callbacks = []
236
+ @mutex = Mutex.new
237
+ @running = false
238
+ @socket = nil
239
+ end
240
+
241
+ def start
242
+ @socket = UDPSocket.new
243
+ @socket.bind("0.0.0.0", @port)
244
+ @port = @socket.addr[1] if @port.zero?
245
+ @running = true
246
+ @receive_thread = Thread.new { receive_loop }
247
+ self
248
+ end
249
+
250
+ def stop
251
+ @running = false
252
+ @socket&.close
253
+ @receive_thread&.join(1)
254
+ self
255
+ end
256
+
257
+ attr_reader :port
258
+
259
+ def connect_to(host, port)
260
+ start unless @running
261
+ add_peer(host, port)
262
+ self
263
+ end
264
+
265
+ def add_peer(host, port)
266
+ validate_peer!(host, port)
267
+ peer = {host: host, port: port}
268
+ @mutex.synchronize { @peers << peer unless @peers.include?(peer) }
269
+ self
270
+ end
271
+
272
+ def remove_peer(host, port)
273
+ @mutex.synchronize { @peers.delete({host: host, port: port}) }
274
+ self
275
+ end
276
+
277
+ def peers
278
+ @mutex.synchronize { @peers.map(&:dup) }
279
+ end
280
+
281
+ def send(message)
282
+ bytes = outbound_midi_bytes(message)
283
+
284
+ packet = Packet.new(
285
+ sequence_number: next_sequence,
286
+ timestamp: (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i,
287
+ ssrc: @ssrc,
288
+ midi_data: bytes
289
+ )
290
+
291
+ packet_bytes = packet.to_bytes
292
+ @mutex.synchronize do
293
+ @peers.each { |peer| @socket&.send(packet_bytes, 0, peer[:host], peer[:port]) }
294
+ end
295
+ self
296
+ end
297
+
298
+ def on_message(&block)
299
+ raise ArgumentError, "on_message requires a block" unless block
300
+
301
+ @mutex.synchronize { @callbacks << block }
302
+ CallbackSubscription.new do
303
+ @mutex.synchronize { @callbacks.delete(block) }
304
+ end
305
+ end
306
+
307
+ def on_error(&block)
308
+ raise ArgumentError, "on_error requires a block" unless block
309
+
310
+ @mutex.synchronize { @error_callbacks << block }
311
+ CallbackSubscription.new do
312
+ @mutex.synchronize { @error_callbacks.delete(block) }
313
+ end
314
+ end
315
+
316
+ def on_control_packet(&block)
317
+ raise ArgumentError, "on_control_packet requires a block" unless block
318
+
319
+ @mutex.synchronize { @control_callbacks << block }
320
+ CallbackSubscription.new do
321
+ @mutex.synchronize { @control_callbacks.delete(block) }
322
+ end
323
+ end
324
+
325
+ def send_control_packet(packet, host, port)
326
+ start unless @running
327
+ @socket&.send(packet.to_bytes, 0, host, port)
328
+ self
329
+ end
330
+
331
+ def close
332
+ stop
333
+ end
334
+
335
+ private
336
+
337
+ def outbound_midi_bytes(message)
338
+ case message
339
+ when Message::Base
340
+ message.to_bytes
341
+ when Array
342
+ return Message.parse_many(message, normalize_note_on_zero: false).flat_map(&:to_bytes) if message.all?(Integer)
343
+
344
+ message.compact.flat_map { |item| outbound_midi_bytes(item) }
345
+ else
346
+ raise InvalidMessageError, "Expected Message or Array"
347
+ end
348
+ end
349
+
350
+ def next_sequence
351
+ @mutex.synchronize do
352
+ seq = @sequence_number
353
+ @sequence_number = (@sequence_number + 1) & 0xFFFF
354
+ seq
355
+ end
356
+ end
357
+
358
+ def receive_loop
359
+ while @running
360
+ begin
361
+ data, address = @socket.recvfrom_nonblock(1024)
362
+ control_packet = ControlPacket.parse(data)
363
+ if control_packet
364
+ notify_control_packet(control_packet, address)
365
+ next
366
+ end
367
+
368
+ packet = Packet.parse(data)
369
+ next unless packet
370
+
371
+ messages = Message.parse_many(packet.midi_data).map { |message| message.with(timestamp: packet.timestamp) }
372
+ callbacks = @mutex.synchronize { @callbacks.dup }
373
+ messages.each { |message| callbacks.each { |cb| cb.call(message) } }
374
+ rescue IO::WaitReadable
375
+ break unless @running
376
+
377
+ begin
378
+ IO.select([@socket], nil, nil, 0.1)
379
+ rescue IOError, SystemCallError
380
+ break
381
+ end
382
+ rescue IOError, SystemCallError
383
+ break
384
+ rescue => e
385
+ notify_error(e, data)
386
+ end
387
+ end
388
+ end
389
+
390
+ def validate_peer!(host, port)
391
+ raise NetworkError, "Peer host must not be empty" if host.to_s.empty?
392
+ return if port.is_a?(Integer) && port.between?(1, 65_535)
393
+
394
+ raise NetworkError, "Peer port must be between 1 and 65535, got #{port.inspect}"
395
+ end
396
+
397
+ def notify_control_packet(packet, address)
398
+ peer = {host: address[3], port: address[1]}
399
+ callbacks = @mutex.synchronize { @control_callbacks.dup }
400
+ callbacks.each { |cb| cb.call(packet, peer) }
401
+ end
402
+
403
+ def notify_error(error, data = nil)
404
+ callbacks = @mutex.synchronize { @error_callbacks.dup }
405
+ callbacks.each { |cb| cb.call(error, data) }
406
+ end
407
+ end
408
+ end
409
+ end
410
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "network/rtp"
4
+ require_relative "network/apple_midi"
5
+ require_relative "network/osc"
6
+
7
+ module Webmidi
8
+ module Network
9
+ end
10
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../callback_subscription"
4
+
5
+ module Webmidi
6
+ module Port
7
+ class Base
8
+ attr_reader :id, :name, :manufacturer, :version, :type
9
+
10
+ def initialize(id:, name:, manufacturer:, version:, type:, transport_handle:, sysex_enabled: false)
11
+ @id = id
12
+ @name = name
13
+ @manufacturer = manufacturer
14
+ @version = version
15
+ @type = type
16
+ @transport_handle = transport_handle
17
+ @sysex_enabled = sysex_enabled
18
+ @state = :connected
19
+ @connection = :closed
20
+ @state_change_callbacks = []
21
+ @mutex = Mutex.new
22
+ end
23
+
24
+ def sysex_enabled?
25
+ @mutex.synchronize { @sysex_enabled }
26
+ end
27
+
28
+ def state
29
+ @mutex.synchronize { @state }
30
+ end
31
+
32
+ def connection
33
+ @mutex.synchronize { @connection }
34
+ end
35
+
36
+ def open
37
+ callbacks = @mutex.synchronize do
38
+ raise PortClosedError, "Port is disconnected" if @state == :disconnected
39
+ return self if @connection == :open
40
+
41
+ @connection = :open
42
+ @state_change_callbacks.dup
43
+ end
44
+ callbacks.each { |cb| cb.call(self) }
45
+ self
46
+ end
47
+
48
+ def close
49
+ callbacks = @mutex.synchronize do
50
+ was_open = @connection == :open
51
+ @connection = :closed
52
+ was_open ? @state_change_callbacks.dup : []
53
+ end
54
+ callbacks.each { |cb| cb.call(self) }
55
+ self
56
+ end
57
+
58
+ def open?
59
+ connection == :open
60
+ end
61
+
62
+ def connected?
63
+ state == :connected
64
+ end
65
+
66
+ def on_state_change(&block)
67
+ raise ArgumentError, "on_state_change requires a block" unless block
68
+
69
+ @mutex.synchronize { @state_change_callbacks << block }
70
+ CallbackSubscription.new do
71
+ @mutex.synchronize { @state_change_callbacks.delete(block) }
72
+ end
73
+ end
74
+
75
+ def disconnect
76
+ callbacks = @mutex.synchronize do
77
+ return self if @state == :disconnected
78
+
79
+ @state = :disconnected
80
+ @connection = :closed
81
+ @transport_handle&.close
82
+ @state_change_callbacks.dup
83
+ end
84
+ callbacks.each { |cb| cb.call(self) }
85
+ self
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webmidi
4
+ module Port
5
+ class Input < Base
6
+ include Enumerable
7
+
8
+ def initialize(**kwargs)
9
+ @error_policy = kwargs.delete(:error_policy) || :notify
10
+ super(**kwargs, type: :input)
11
+ @message_callbacks = []
12
+ @typed_callbacks = []
13
+ @error_callbacks = []
14
+ end
15
+
16
+ def on_message(&block)
17
+ raise ArgumentError, "on_message requires a block" unless block
18
+
19
+ open unless open?
20
+ @mutex.synchronize { @message_callbacks << block }
21
+ CallbackSubscription.new do
22
+ @mutex.synchronize { @message_callbacks.delete(block) }
23
+ end
24
+ end
25
+
26
+ def on_note_on(&block)
27
+ register_typed_callback(Message::Channel::NoteOn, &block)
28
+ end
29
+
30
+ def on_note_off(&block)
31
+ register_typed_callback(Message::Channel::NoteOff, &block)
32
+ end
33
+
34
+ def on_control_change(&block)
35
+ register_typed_callback(Message::Channel::ControlChange, &block)
36
+ end
37
+
38
+ def on_program_change(&block)
39
+ register_typed_callback(Message::Channel::ProgramChange, &block)
40
+ end
41
+
42
+ def on_pitch_bend(&block)
43
+ register_typed_callback(Message::Channel::PitchBend, &block)
44
+ end
45
+
46
+ def on_sysex(&block)
47
+ register_typed_callback(Message::System::SysEx, &block)
48
+ end
49
+
50
+ def on_clock(&block)
51
+ register_typed_callback(Message::System::Clock, &block)
52
+ end
53
+
54
+ def on_type(matcher, &block)
55
+ register_typed_callback(matcher, &block)
56
+ end
57
+
58
+ def on_error(&block)
59
+ raise ArgumentError, "on_error requires a block" unless block
60
+
61
+ open unless open?
62
+ @mutex.synchronize { @error_callbacks << block }
63
+ CallbackSubscription.new do
64
+ @mutex.synchronize { @error_callbacks.delete(block) }
65
+ end
66
+ end
67
+
68
+ def each(timeout: 0.1, stop_when: nil, &block)
69
+ return enum_for(:each, timeout: timeout, stop_when: stop_when) unless block_given?
70
+
71
+ open unless open?
72
+ while open?
73
+ break if stop_when&.call
74
+
75
+ bytes = @transport_handle.read(timeout: timeout)
76
+ next unless bytes
77
+
78
+ begin
79
+ Message.parse_many(bytes).each do |msg|
80
+ next if masked_sysex?(msg)
81
+
82
+ block.call(msg)
83
+ end
84
+ rescue InvalidMessageError => e
85
+ handle_parse_error(e, bytes)
86
+ end
87
+ end
88
+ end
89
+
90
+ def messages(**kwargs)
91
+ each(**kwargs).lazy
92
+ end
93
+
94
+ def pipe(stack = nil)
95
+ require_relative "../middleware/pipeline"
96
+
97
+ Middleware::Pipeline.new(self, stack)
98
+ end
99
+
100
+ def dispatch(bytes)
101
+ return unless open?
102
+
103
+ Message.parse_many(bytes).each { |msg| dispatch_message(msg) }
104
+ rescue InvalidMessageError => e
105
+ handle_parse_error(e, bytes)
106
+ end
107
+
108
+ private
109
+
110
+ def register_typed_callback(klass, &block)
111
+ raise ArgumentError, "typed callback requires a block" unless block
112
+
113
+ open unless open?
114
+ entry = [klass, block]
115
+ @mutex.synchronize { @typed_callbacks << entry }
116
+ CallbackSubscription.new do
117
+ @mutex.synchronize { @typed_callbacks.delete(entry) }
118
+ end
119
+ end
120
+
121
+ def dispatch_message(msg)
122
+ return if masked_sysex?(msg)
123
+
124
+ callbacks, typed = @mutex.synchronize do
125
+ [
126
+ @message_callbacks.dup,
127
+ @typed_callbacks.select { |matcher, _callback| callback_matches?(matcher, msg) }.map(&:last)
128
+ ]
129
+ end
130
+ callbacks.each { |cb| cb.call(msg) }
131
+ typed.each { |cb| cb.call(msg) }
132
+ end
133
+
134
+ def callback_matches?(matcher, msg)
135
+ case matcher
136
+ when Class, Module
137
+ msg.is_a?(matcher)
138
+ when Proc
139
+ matcher.call(msg)
140
+ else
141
+ matcher === msg
142
+ end
143
+ end
144
+
145
+ def masked_sysex?(msg)
146
+ msg.is_a?(Message::System::SysEx) && !sysex_enabled?
147
+ end
148
+
149
+ def handle_parse_error(error, bytes)
150
+ raise error if @error_policy == :raise
151
+
152
+ callbacks = @mutex.synchronize { @error_callbacks.dup }
153
+ callbacks.each { |cb| cb.call(error, bytes) }
154
+ nil
155
+ end
156
+ end
157
+ end
158
+ end