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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +73 -0
- data/Rakefile +48 -0
- data/lib/webmidi/access.rb +170 -0
- data/lib/webmidi/callback_subscription.rb +26 -0
- data/lib/webmidi/clock.rb +129 -0
- data/lib/webmidi/configuration.rb +43 -0
- data/lib/webmidi/error.rb +26 -0
- data/lib/webmidi/message/base.rb +64 -0
- data/lib/webmidi/message/channel.rb +238 -0
- data/lib/webmidi/message/parser.rb +308 -0
- data/lib/webmidi/message/system.rb +162 -0
- data/lib/webmidi/message/ump.rb +675 -0
- data/lib/webmidi/message.rb +154 -0
- data/lib/webmidi/middleware/base.rb +16 -0
- data/lib/webmidi/middleware/channel_map.rb +36 -0
- data/lib/webmidi/middleware/filter.rb +22 -0
- data/lib/webmidi/middleware/logger.rb +17 -0
- data/lib/webmidi/middleware/note_range_filter.rb +34 -0
- data/lib/webmidi/middleware/panic.rb +73 -0
- data/lib/webmidi/middleware/pipeline.rb +19 -0
- data/lib/webmidi/middleware/recorder.rb +123 -0
- data/lib/webmidi/middleware/split_by_channel.rb +66 -0
- data/lib/webmidi/middleware/stack.rb +55 -0
- data/lib/webmidi/middleware/timing_gate.rb +58 -0
- data/lib/webmidi/middleware/transpose.rb +30 -0
- data/lib/webmidi/middleware/velocity_clamp.rb +37 -0
- data/lib/webmidi/middleware/velocity_scale.rb +55 -0
- data/lib/webmidi/middleware.rb +21 -0
- data/lib/webmidi/music/chord.rb +90 -0
- data/lib/webmidi/music/note.rb +102 -0
- data/lib/webmidi/music/rhythm.rb +92 -0
- data/lib/webmidi/music/scale.rb +85 -0
- data/lib/webmidi/music.rb +24 -0
- data/lib/webmidi/network/apple_midi.rb +189 -0
- data/lib/webmidi/network/osc.rb +205 -0
- data/lib/webmidi/network/rtp.rb +410 -0
- data/lib/webmidi/network.rb +10 -0
- data/lib/webmidi/port/base.rb +89 -0
- data/lib/webmidi/port/input.rb +158 -0
- data/lib/webmidi/port/map.rb +65 -0
- data/lib/webmidi/port/output.rb +208 -0
- data/lib/webmidi/port.rb +11 -0
- data/lib/webmidi/smf/event.rb +206 -0
- data/lib/webmidi/smf/reader.rb +237 -0
- data/lib/webmidi/smf/sequence.rb +135 -0
- data/lib/webmidi/smf/tempo_map.rb +107 -0
- data/lib/webmidi/smf/track.rb +130 -0
- data/lib/webmidi/smf/writer.rb +121 -0
- data/lib/webmidi/smf.rb +13 -0
- data/lib/webmidi/transport/adapter.rb +46 -0
- data/lib/webmidi/transport/base.rb +59 -0
- data/lib/webmidi/transport/device_info.rb +7 -0
- data/lib/webmidi/transport/null.rb +81 -0
- data/lib/webmidi/transport/virtual.rb +184 -0
- data/lib/webmidi/transport.rb +80 -0
- data/lib/webmidi/version.rb +5 -0
- data/lib/webmidi/virtual/loopback.rb +45 -0
- data/lib/webmidi/virtual/port.rb +48 -0
- data/lib/webmidi/virtual.rb +9 -0
- data/lib/webmidi.rb +19 -0
- data/webmidi.gemspec +32 -0
- 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,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
|