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,675 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module Message
|
|
5
|
+
module UMP
|
|
6
|
+
MESSAGE_TYPES = {
|
|
7
|
+
utility: 0x0,
|
|
8
|
+
system_common: 0x1,
|
|
9
|
+
channel_voice_32: 0x2,
|
|
10
|
+
data_64: 0x3,
|
|
11
|
+
channel_voice_64: 0x4,
|
|
12
|
+
data_128: 0x5,
|
|
13
|
+
flex_data: 0xD
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
WORD_COUNTS = {
|
|
17
|
+
utility: 1,
|
|
18
|
+
system_common: 1,
|
|
19
|
+
channel_voice_32: 1,
|
|
20
|
+
data_64: 2,
|
|
21
|
+
channel_voice_64: 2,
|
|
22
|
+
data_128: 4,
|
|
23
|
+
flex_data: 4
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
STATUS_NIBBLES = {
|
|
27
|
+
note_off: 0x8,
|
|
28
|
+
note_on: 0x9,
|
|
29
|
+
poly_pressure: 0xA,
|
|
30
|
+
control_change: 0xB,
|
|
31
|
+
program_change: 0xC,
|
|
32
|
+
channel_pressure: 0xD,
|
|
33
|
+
pitch_bend: 0xE
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
STATUS_BY_NIBBLE = STATUS_NIBBLES.invert.freeze
|
|
37
|
+
|
|
38
|
+
SYSTEM_COMMON_STATUSES = {
|
|
39
|
+
0xF1 => :time_code,
|
|
40
|
+
0xF2 => :song_position,
|
|
41
|
+
0xF3 => :song_select,
|
|
42
|
+
0xF6 => :tune_request,
|
|
43
|
+
0xF8 => :clock,
|
|
44
|
+
0xFA => :start,
|
|
45
|
+
0xFB => :continue,
|
|
46
|
+
0xFC => :stop,
|
|
47
|
+
0xFE => :active_sensing,
|
|
48
|
+
0xFF => :system_reset
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
DATA_PACKET_FORMATS = {
|
|
52
|
+
complete: 0x0,
|
|
53
|
+
start: 0x1,
|
|
54
|
+
continue: 0x2,
|
|
55
|
+
end: 0x3
|
|
56
|
+
}.freeze
|
|
57
|
+
DATA_PACKET_FORMAT_BY_NIBBLE = DATA_PACKET_FORMATS.invert.freeze
|
|
58
|
+
|
|
59
|
+
CHANNEL_VOICE_32_STATUSES = %i[
|
|
60
|
+
note_off note_on poly_pressure control_change program_change channel_pressure pitch_bend
|
|
61
|
+
].freeze
|
|
62
|
+
CHANNEL_VOICE_64_STATUSES = %i[
|
|
63
|
+
note_off note_on poly_pressure control_change program_change channel_pressure pitch_bend
|
|
64
|
+
].freeze
|
|
65
|
+
|
|
66
|
+
MIDI1_CHANNEL_VOICE_TO_UMP = {
|
|
67
|
+
Channel::NoteOff => {status: :note_off, data: :note, value: :velocity, scale: :scale_7_to_16},
|
|
68
|
+
Channel::NoteOn => {status: :note_on, data: :note, value: :velocity, scale: :scale_7_to_16},
|
|
69
|
+
Channel::PolyphonicPressure => {status: :poly_pressure, data: :note, value: :pressure, scale: :scale_7_to_16},
|
|
70
|
+
Channel::ControlChange => {status: :control_change, data: :cc, value: :value, scale: :scale_7_to_32},
|
|
71
|
+
Channel::ProgramChange => {status: :program_change, data: :program},
|
|
72
|
+
Channel::ChannelPressure => {status: :channel_pressure, value: :pressure, scale: :scale_7_to_32},
|
|
73
|
+
Channel::PitchBend => {status: :pitch_bend, value: :value, scale: :scale_14_to_32}
|
|
74
|
+
}.freeze
|
|
75
|
+
|
|
76
|
+
UMP_CHANNEL_VOICE_TO_MIDI1 = {
|
|
77
|
+
note_off: {class: Channel::NoteOff, fields: {note: :note, velocity: [:velocity, :scale_16_to_7]}},
|
|
78
|
+
note_on: {class: Channel::NoteOn, fields: {note: :note, velocity: [:velocity, :scale_16_to_7]}},
|
|
79
|
+
poly_pressure: {
|
|
80
|
+
class: Channel::PolyphonicPressure,
|
|
81
|
+
fields: {note: :note, pressure: [:velocity, :scale_16_to_7]}
|
|
82
|
+
},
|
|
83
|
+
control_change: {class: Channel::ControlChange, fields: {cc: :note, value: [:velocity, :scale_32_to_7]}},
|
|
84
|
+
program_change: {class: Channel::ProgramChange, fields: {program: :note}},
|
|
85
|
+
channel_pressure: {class: Channel::ChannelPressure, fields: {pressure: [:velocity, :scale_32_to_7]}},
|
|
86
|
+
pitch_bend: {class: Channel::PitchBend, fields: {value: [:velocity, :scale_32_to_14]}}
|
|
87
|
+
}.freeze
|
|
88
|
+
|
|
89
|
+
class Base < Message::Base
|
|
90
|
+
attr_reader :message_type, :group
|
|
91
|
+
|
|
92
|
+
def initialize(message_type:, group: 0, timestamp: nil)
|
|
93
|
+
validate_message_type!(message_type)
|
|
94
|
+
validate_range!(group, "Group", 0, 15)
|
|
95
|
+
@message_type = message_type
|
|
96
|
+
@group = group
|
|
97
|
+
super(timestamp: timestamp)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def deconstruct_keys(keys)
|
|
101
|
+
{message_type: @message_type, group: @group}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def with(**changes)
|
|
105
|
+
changes = changes.dup
|
|
106
|
+
next_timestamp = changes.key?(:timestamp) ? changes.delete(:timestamp) : @timestamp
|
|
107
|
+
self.class.new(**constructor_attributes.merge(changes), timestamp: next_timestamp)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def constructor_attributes
|
|
113
|
+
attributes = deconstruct_keys(nil)
|
|
114
|
+
attributes.delete(:message_type) unless instance_of?(Base) || instance_of?(Raw)
|
|
115
|
+
attributes
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def validate_message_type!(message_type)
|
|
119
|
+
return if MESSAGE_TYPES.key?(message_type)
|
|
120
|
+
|
|
121
|
+
raise InvalidMessageError, "Unknown UMP message type: #{message_type.inspect}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def validate_range!(value, name, min, max)
|
|
125
|
+
return if value.is_a?(Integer) && value.between?(min, max)
|
|
126
|
+
|
|
127
|
+
raise InvalidMessageError, "#{name} must be between #{min} and #{max}, got #{value.inspect}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
class Raw < Base
|
|
132
|
+
attr_reader :words
|
|
133
|
+
|
|
134
|
+
def initialize(message_type:, words:, group: 0, timestamp: nil)
|
|
135
|
+
words = normalize_words!(words)
|
|
136
|
+
validate_message_type!(message_type)
|
|
137
|
+
validate_word_count!(message_type, words)
|
|
138
|
+
validate_range!(group, "Group", 0, 15)
|
|
139
|
+
validate_word_header!(message_type, group, words.first)
|
|
140
|
+
@words = words.dup.freeze
|
|
141
|
+
super(message_type: message_type, group: group, timestamp: timestamp)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def to_bytes
|
|
145
|
+
words_to_bytes(@words)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def deconstruct_keys(keys)
|
|
149
|
+
super.merge(words: @words)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def with(**changes)
|
|
153
|
+
changes = changes.dup
|
|
154
|
+
next_timestamp = changes.key?(:timestamp) ? changes.delete(:timestamp) : @timestamp
|
|
155
|
+
attributes = constructor_attributes.merge(changes)
|
|
156
|
+
attributes[:words] = words_with_group(attributes[:words], attributes[:group])
|
|
157
|
+
self.class.new(**attributes, timestamp: next_timestamp)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def constructor_attributes
|
|
163
|
+
attributes = {message_type: @message_type, group: @group, words: @words}
|
|
164
|
+
attributes.delete(:message_type) unless instance_of?(Raw)
|
|
165
|
+
attributes
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def words_with_group(words, group)
|
|
169
|
+
next_words = words.dup
|
|
170
|
+
next_words[0] = (next_words[0] & 0xF0FF_FFFF) | (group << 24)
|
|
171
|
+
next_words
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def normalize_words!(words)
|
|
175
|
+
unless words.respond_to?(:each)
|
|
176
|
+
raise InvalidMessageError, "UMP words must be enumerable, got #{words.class}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
words = words.to_a
|
|
180
|
+
words.each_with_index do |word, index|
|
|
181
|
+
validate_range!(word, "Word at index #{index}", 0, 0xFFFF_FFFF)
|
|
182
|
+
end
|
|
183
|
+
words
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def validate_word_count!(message_type, words)
|
|
187
|
+
expected = WORD_COUNTS.fetch(message_type)
|
|
188
|
+
return if words.size == expected
|
|
189
|
+
|
|
190
|
+
raise InvalidMessageError, "#{message_type} UMP must have #{expected} word(s), got #{words.size}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def validate_word_header!(message_type, group, word)
|
|
194
|
+
actual_type = (word >> 28) & 0x0F
|
|
195
|
+
expected_type = MESSAGE_TYPES.fetch(message_type)
|
|
196
|
+
unless actual_type == expected_type
|
|
197
|
+
raise InvalidMessageError,
|
|
198
|
+
"#{message_type} UMP first word has message type 0x#{actual_type.to_s(16).upcase}"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
actual_group = (word >> 24) & 0x0F
|
|
202
|
+
return if actual_group == group
|
|
203
|
+
|
|
204
|
+
raise InvalidMessageError,
|
|
205
|
+
"#{message_type} UMP first word has group #{actual_group}, got group #{group}"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def words_to_bytes(words)
|
|
209
|
+
words.flat_map do |word|
|
|
210
|
+
[(word >> 24) & 0xFF, (word >> 16) & 0xFF, (word >> 8) & 0xFF, word & 0xFF]
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
class Utility < Raw
|
|
216
|
+
def initialize(words:, group: 0, timestamp: nil)
|
|
217
|
+
super(message_type: :utility, words: words, group: group, timestamp: timestamp)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def status
|
|
221
|
+
(@words.first >> 16) & 0xFF
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def payload
|
|
225
|
+
@words.first & 0xFFFF
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def deconstruct_keys(keys)
|
|
229
|
+
super.merge(status: status, payload: payload)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
class SystemCommon < Raw
|
|
234
|
+
def initialize(words:, group: 0, timestamp: nil)
|
|
235
|
+
super(message_type: :system_common, words: words, group: group, timestamp: timestamp)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def status_byte
|
|
239
|
+
(@words.first >> 16) & 0xFF
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def status
|
|
243
|
+
SYSTEM_COMMON_STATUSES.fetch(status_byte, :unknown)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def data1
|
|
247
|
+
(@words.first >> 8) & 0x7F
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def data2
|
|
251
|
+
@words.first & 0x7F
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def deconstruct_keys(keys)
|
|
255
|
+
super.merge(status_byte: status_byte, status: status, data1: data1, data2: data2)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
class Data64 < Raw
|
|
260
|
+
def initialize(words:, group: 0, timestamp: nil)
|
|
261
|
+
super(message_type: :data_64, words: words, group: group, timestamp: timestamp)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def packet_format
|
|
265
|
+
DATA_PACKET_FORMAT_BY_NIBBLE.fetch((@words.first >> 20) & 0x0F, :unknown)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def byte_count
|
|
269
|
+
(@words.first >> 16) & 0x0F
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def data_bytes
|
|
273
|
+
[
|
|
274
|
+
(@words.first >> 8) & 0xFF,
|
|
275
|
+
@words.first & 0xFF,
|
|
276
|
+
(@words[1] >> 24) & 0xFF,
|
|
277
|
+
(@words[1] >> 16) & 0xFF,
|
|
278
|
+
(@words[1] >> 8) & 0xFF,
|
|
279
|
+
@words[1] & 0xFF
|
|
280
|
+
].first(byte_count)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def deconstruct_keys(keys)
|
|
284
|
+
super.merge(packet_format: packet_format, byte_count: byte_count, data_bytes: data_bytes)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
class Data128 < Raw
|
|
289
|
+
def initialize(words:, group: 0, timestamp: nil)
|
|
290
|
+
super(message_type: :data_128, words: words, group: group, timestamp: timestamp)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def packet_format
|
|
294
|
+
DATA_PACKET_FORMAT_BY_NIBBLE.fetch((@words.first >> 20) & 0x0F, :unknown)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def byte_count
|
|
298
|
+
(@words.first >> 16) & 0x0F
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def stream_id
|
|
302
|
+
(@words.first >> 8) & 0xFF
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def data_bytes
|
|
306
|
+
word_bytes(@words.first & 0xFF, *@words[1..]).first(byte_count)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def deconstruct_keys(keys)
|
|
310
|
+
super.merge(
|
|
311
|
+
packet_format: packet_format,
|
|
312
|
+
byte_count: byte_count,
|
|
313
|
+
stream_id: stream_id,
|
|
314
|
+
data_bytes: data_bytes
|
|
315
|
+
)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
private
|
|
319
|
+
|
|
320
|
+
def word_bytes(first_byte, *words)
|
|
321
|
+
[first_byte] + words.flat_map do |word|
|
|
322
|
+
[(word >> 24) & 0xFF, (word >> 16) & 0xFF, (word >> 8) & 0xFF, word & 0xFF]
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
class FlexData < Raw
|
|
328
|
+
def initialize(words:, group: 0, timestamp: nil)
|
|
329
|
+
super(message_type: :flex_data, words: words, group: group, timestamp: timestamp)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def format
|
|
333
|
+
(@words.first >> 22) & 0x03
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def address
|
|
337
|
+
(@words.first >> 20) & 0x03
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def channel
|
|
341
|
+
(@words.first >> 16) & 0x0F
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def status_bank
|
|
345
|
+
(@words.first >> 8) & 0xFF
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def status
|
|
349
|
+
@words.first & 0xFF
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def data_words
|
|
353
|
+
@words[1..]
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def deconstruct_keys(keys)
|
|
357
|
+
super.merge(
|
|
358
|
+
format: format,
|
|
359
|
+
address: address,
|
|
360
|
+
channel: channel,
|
|
361
|
+
status_bank: status_bank,
|
|
362
|
+
status: status,
|
|
363
|
+
data_words: data_words
|
|
364
|
+
)
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
class ChannelVoice64 < Base
|
|
369
|
+
attr_reader :status, :channel, :note, :velocity, :attribute_type, :attribute
|
|
370
|
+
|
|
371
|
+
def initialize(status:, channel: 0, note: 0, velocity: 0, attribute_type: 0, attribute: 0, group: 0,
|
|
372
|
+
timestamp: nil)
|
|
373
|
+
validate_status!(status)
|
|
374
|
+
validate_range!(channel, "Channel", 0, 15)
|
|
375
|
+
validate_range!(note, "Note/controller", 0, 127)
|
|
376
|
+
validate_range!(attribute_type, "Attribute type", 0, 255)
|
|
377
|
+
validate_range!(attribute, "Attribute", 0, 0xFFFF)
|
|
378
|
+
validate_velocity!(status, velocity)
|
|
379
|
+
|
|
380
|
+
@status = status
|
|
381
|
+
@channel = channel
|
|
382
|
+
@note = note
|
|
383
|
+
@velocity = velocity
|
|
384
|
+
@attribute_type = attribute_type
|
|
385
|
+
@attribute = attribute
|
|
386
|
+
super(message_type: :channel_voice_64, group: group, timestamp: timestamp)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def to_bytes
|
|
390
|
+
word1 = (MESSAGE_TYPES[:channel_voice_64] << 28) |
|
|
391
|
+
(@group << 24) |
|
|
392
|
+
(status_nibble << 20) |
|
|
393
|
+
(@channel << 16) |
|
|
394
|
+
(@note << 8) |
|
|
395
|
+
data2
|
|
396
|
+
word2 = word2_value
|
|
397
|
+
|
|
398
|
+
[(word1 >> 24) & 0xFF, (word1 >> 16) & 0xFF, (word1 >> 8) & 0xFF, word1 & 0xFF,
|
|
399
|
+
(word2 >> 24) & 0xFF, (word2 >> 16) & 0xFF, (word2 >> 8) & 0xFF, word2 & 0xFF]
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def deconstruct_keys(keys)
|
|
403
|
+
super.merge(
|
|
404
|
+
status: @status, channel: @channel, note: @note, velocity: @velocity,
|
|
405
|
+
attribute_type: @attribute_type, attribute: @attribute
|
|
406
|
+
)
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
private
|
|
410
|
+
|
|
411
|
+
def validate_status!(status)
|
|
412
|
+
return if CHANNEL_VOICE_64_STATUSES.include?(status)
|
|
413
|
+
|
|
414
|
+
raise InvalidMessageError, "Unknown MIDI 2.0 channel voice status: #{status.inspect}"
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def validate_velocity!(status, velocity)
|
|
418
|
+
max = %i[note_on note_off poly_pressure].include?(status) ? 0xFFFF : 0xFFFF_FFFF
|
|
419
|
+
validate_range!(velocity, "Value", 0, max)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def status_nibble
|
|
423
|
+
STATUS_NIBBLES.fetch(@status)
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def data2
|
|
427
|
+
%i[note_on note_off poly_pressure].include?(@status) ? @attribute_type : 0
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def word2_value
|
|
431
|
+
if %i[note_on note_off poly_pressure].include?(@status)
|
|
432
|
+
(@velocity << 16) | @attribute
|
|
433
|
+
else
|
|
434
|
+
@velocity
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
class ChannelVoice32 < Base
|
|
440
|
+
attr_reader :status, :channel, :data1, :data2
|
|
441
|
+
|
|
442
|
+
def initialize(status:, channel: 0, data1: 0, data2: 0, group: 0, timestamp: nil)
|
|
443
|
+
validate_status!(status)
|
|
444
|
+
validate_range!(channel, "Channel", 0, 15)
|
|
445
|
+
validate_range!(data1, "Data byte 1", 0, 127)
|
|
446
|
+
validate_range!(data2, "Data byte 2", 0, 127)
|
|
447
|
+
@status = status
|
|
448
|
+
@channel = channel
|
|
449
|
+
@data1 = data1
|
|
450
|
+
@data2 = data2
|
|
451
|
+
super(message_type: :channel_voice_32, group: group, timestamp: timestamp)
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def to_bytes
|
|
455
|
+
word = (MESSAGE_TYPES[:channel_voice_32] << 28) |
|
|
456
|
+
(@group << 24) |
|
|
457
|
+
(status_nibble << 20) |
|
|
458
|
+
(@channel << 16) |
|
|
459
|
+
(@data1 << 8) |
|
|
460
|
+
@data2
|
|
461
|
+
|
|
462
|
+
[(word >> 24) & 0xFF, (word >> 16) & 0xFF, (word >> 8) & 0xFF, word & 0xFF]
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def deconstruct_keys(keys)
|
|
466
|
+
super.merge(status: @status, channel: @channel, data1: @data1, data2: @data2)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
private
|
|
470
|
+
|
|
471
|
+
def validate_status!(status)
|
|
472
|
+
return if CHANNEL_VOICE_32_STATUSES.include?(status)
|
|
473
|
+
|
|
474
|
+
raise InvalidMessageError, "Unknown MIDI 1.0 channel voice status: #{status.inspect}"
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def status_nibble
|
|
478
|
+
STATUS_NIBBLES.fetch(@status)
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
module_function
|
|
483
|
+
|
|
484
|
+
def from_bytes(*bytes)
|
|
485
|
+
bytes = bytes.flatten
|
|
486
|
+
unless bytes.size.positive? && (bytes.size % 4).zero?
|
|
487
|
+
raise InvalidMessageError, "UMP byte input must be a positive multiple of 4 bytes"
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
words = bytes.each_slice(4).map do |slice|
|
|
491
|
+
slice.each_with_index do |byte, index|
|
|
492
|
+
unless byte.is_a?(Integer) && byte.between?(0, 255)
|
|
493
|
+
raise InvalidMessageError, "Byte at index #{index} must be between 0 and 255, got #{byte.inspect}"
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
(slice[0] << 24) | (slice[1] << 16) | (slice[2] << 8) | slice[3]
|
|
497
|
+
end
|
|
498
|
+
from_words(*words)
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def from_words(*words)
|
|
502
|
+
words = words.flatten
|
|
503
|
+
validate_words!(words)
|
|
504
|
+
message_type = type_from_word(words.first)
|
|
505
|
+
expected_words = WORD_COUNTS.fetch(message_type) do
|
|
506
|
+
raise InvalidMessageError, "Unsupported UMP message type: #{format("0x%X", words.first >> 28)}"
|
|
507
|
+
end
|
|
508
|
+
return parse_words(message_type, words) if words.size == expected_words
|
|
509
|
+
|
|
510
|
+
raise InvalidMessageError, "#{message_type} UMP expects #{expected_words} word(s), got #{words.size}"
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def upgrade(midi1_message, group: Webmidi.configuration.default_group)
|
|
514
|
+
spec = MIDI1_CHANNEL_VOICE_TO_UMP.find { |message_class, _| midi1_message.is_a?(message_class) }&.last
|
|
515
|
+
unless spec
|
|
516
|
+
raise InvalidMessageError, "Cannot upgrade #{midi1_message.class} to MIDI 2.0"
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
ChannelVoice64.new(**upgrade_attributes(midi1_message, spec, group))
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def downgrade(midi2_message)
|
|
523
|
+
case midi2_message
|
|
524
|
+
when ChannelVoice64
|
|
525
|
+
downgrade_channel_voice64(midi2_message)
|
|
526
|
+
else
|
|
527
|
+
raise InvalidMessageError, "Cannot downgrade #{midi2_message.class} to MIDI 1.0"
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def parse_words(message_type, words)
|
|
532
|
+
group = (words.first >> 24) & 0x0F
|
|
533
|
+
case message_type
|
|
534
|
+
when :channel_voice_32
|
|
535
|
+
parse_channel_voice32(words.first, group)
|
|
536
|
+
when :channel_voice_64
|
|
537
|
+
parse_channel_voice64(words, group)
|
|
538
|
+
when :utility
|
|
539
|
+
Utility.new(words: words, group: group)
|
|
540
|
+
when :system_common
|
|
541
|
+
SystemCommon.new(words: words, group: group)
|
|
542
|
+
when :data_64
|
|
543
|
+
Data64.new(words: words, group: group)
|
|
544
|
+
when :data_128
|
|
545
|
+
Data128.new(words: words, group: group)
|
|
546
|
+
when :flex_data
|
|
547
|
+
FlexData.new(words: words, group: group)
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def parse_channel_voice32(word, group)
|
|
552
|
+
status = status_from_nibble((word >> 20) & 0x0F)
|
|
553
|
+
ChannelVoice32.new(
|
|
554
|
+
status: status,
|
|
555
|
+
channel: (word >> 16) & 0x0F,
|
|
556
|
+
data1: (word >> 8) & 0x7F,
|
|
557
|
+
data2: word & 0x7F,
|
|
558
|
+
group: group
|
|
559
|
+
)
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def parse_channel_voice64(words, group)
|
|
563
|
+
word1, word2 = words
|
|
564
|
+
status = status_from_nibble((word1 >> 20) & 0x0F)
|
|
565
|
+
data1 = (word1 >> 8) & 0xFF
|
|
566
|
+
data2 = word1 & 0xFF
|
|
567
|
+
|
|
568
|
+
if %i[note_on note_off poly_pressure].include?(status)
|
|
569
|
+
ChannelVoice64.new(
|
|
570
|
+
status: status,
|
|
571
|
+
channel: (word1 >> 16) & 0x0F,
|
|
572
|
+
note: data1,
|
|
573
|
+
velocity: (word2 >> 16) & 0xFFFF,
|
|
574
|
+
attribute_type: data2,
|
|
575
|
+
attribute: word2 & 0xFFFF,
|
|
576
|
+
group: group
|
|
577
|
+
)
|
|
578
|
+
else
|
|
579
|
+
ChannelVoice64.new(
|
|
580
|
+
status: status,
|
|
581
|
+
channel: (word1 >> 16) & 0x0F,
|
|
582
|
+
note: data1,
|
|
583
|
+
velocity: word2,
|
|
584
|
+
group: group
|
|
585
|
+
)
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def downgrade_channel_voice64(message)
|
|
590
|
+
spec = UMP_CHANNEL_VOICE_TO_MIDI1[message.status]
|
|
591
|
+
unless spec
|
|
592
|
+
raise InvalidMessageError, "Cannot downgrade status #{message.status}"
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
spec[:class].new(**downgrade_attributes(message, spec))
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def upgrade_attributes(message, spec, group)
|
|
599
|
+
attributes = {status: spec.fetch(:status), channel: message.channel, group: group}
|
|
600
|
+
attributes[:note] = message.public_send(spec[:data]) if spec[:data]
|
|
601
|
+
attributes[:velocity] = scaled_value(message.public_send(spec[:value]), spec[:scale]) if spec[:value]
|
|
602
|
+
attributes
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
def downgrade_attributes(message, spec)
|
|
606
|
+
spec[:fields].each_with_object({channel: message.channel}) do |(target, source), attributes|
|
|
607
|
+
attributes[target] = field_value(message, source)
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
def field_value(message, source)
|
|
612
|
+
return message.public_send(source) unless source.is_a?(Array)
|
|
613
|
+
|
|
614
|
+
field, scale = source
|
|
615
|
+
scaled_value(message.public_send(field), scale)
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def scaled_value(value, scale)
|
|
619
|
+
scale ? send(scale, value) : value
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
def type_from_word(word)
|
|
623
|
+
type = (word >> 28) & 0x0F
|
|
624
|
+
MESSAGE_TYPES.key(type)
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def status_from_nibble(nibble)
|
|
628
|
+
STATUS_BY_NIBBLE.fetch(nibble) do
|
|
629
|
+
raise InvalidMessageError, "Unknown channel voice status nibble: #{format("0x%X", nibble)}"
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def validate_words!(words)
|
|
634
|
+
raise InvalidMessageError, "UMP words cannot be empty" if words.empty?
|
|
635
|
+
|
|
636
|
+
words.each_with_index do |word, index|
|
|
637
|
+
next if word.is_a?(Integer) && word.between?(0, 0xFFFF_FFFF)
|
|
638
|
+
|
|
639
|
+
raise InvalidMessageError, "Word at index #{index} must be between 0 and 0xFFFFFFFF, got #{word.inspect}"
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def scale_7_to_16(value)
|
|
644
|
+
((value * 0xFFFF).to_f / 0x7F).round
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def scale_16_to_7(value)
|
|
648
|
+
((value * 0x7F).to_f / 0xFFFF).round.clamp(0, 127)
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
def scale_7_to_32(value)
|
|
652
|
+
((value * 0xFFFF_FFFF).to_f / 0x7F).round
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
def scale_32_to_7(value)
|
|
656
|
+
((value * 0x7F).to_f / 0xFFFF_FFFF).round.clamp(0, 127)
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
def scale_14_to_32(value)
|
|
660
|
+
((value * 0xFFFF_FFFF).to_f / 0x3FFF).round
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def scale_32_to_14(value)
|
|
664
|
+
((value * 0x3FFF).to_f / 0xFFFF_FFFF).round.clamp(0, 16_383)
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
private_class_method :parse_words, :parse_channel_voice32, :parse_channel_voice64,
|
|
668
|
+
:downgrade_channel_voice64, :upgrade_attributes, :downgrade_attributes,
|
|
669
|
+
:field_value, :scaled_value, :type_from_word, :status_from_nibble,
|
|
670
|
+
:validate_words!, :scale_7_to_16, :scale_16_to_7,
|
|
671
|
+
:scale_7_to_32, :scale_32_to_7, :scale_14_to_32,
|
|
672
|
+
:scale_32_to_14
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
end
|