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,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