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,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module Port
|
|
5
|
+
class Map
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
def initialize(ports = [], mutable: true)
|
|
9
|
+
@ports = {}
|
|
10
|
+
@mutable = mutable
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
ports.each { |port| @ports[port.id] = port }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def [](id_or_name)
|
|
16
|
+
@mutex.synchronize do
|
|
17
|
+
@ports[id_or_name] || @ports.values.find { |p| p.name == id_or_name }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def each(&block)
|
|
22
|
+
to_a.each(&block)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def size
|
|
26
|
+
@mutex.synchronize { @ports.size }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def add(port)
|
|
30
|
+
ensure_mutable!
|
|
31
|
+
@mutex.synchronize { @ports[port.id] = port }
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def remove(port_or_id)
|
|
36
|
+
ensure_mutable!
|
|
37
|
+
id = port_or_id.is_a?(String) ? port_or_id : port_or_id.id
|
|
38
|
+
@mutex.synchronize { @ports.delete(id) }
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_a
|
|
43
|
+
@mutex.synchronize { @ports.values.dup }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_h
|
|
47
|
+
@mutex.synchronize { @ports.dup }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def snapshot
|
|
51
|
+
self.class.new(to_a, mutable: false)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def empty?
|
|
55
|
+
@mutex.synchronize { @ports.empty? }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def ensure_mutable!
|
|
61
|
+
raise FrozenError, "Port::Map snapshot is read-only" unless @mutable
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module Port
|
|
5
|
+
class Output < Base
|
|
6
|
+
def initialize(**kwargs)
|
|
7
|
+
super(**kwargs, type: :output)
|
|
8
|
+
@middleware_stack = nil
|
|
9
|
+
@scheduled_messages = []
|
|
10
|
+
@scheduler_mutex = Mutex.new
|
|
11
|
+
@scheduler_cv = ConditionVariable.new
|
|
12
|
+
@scheduler_thread = nil
|
|
13
|
+
@scheduler_shutdown = false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def send(message, timestamp: nil)
|
|
17
|
+
open unless open?
|
|
18
|
+
message = apply_middleware(message)
|
|
19
|
+
return self unless message
|
|
20
|
+
|
|
21
|
+
byte_messages = outbound_byte_messages(message)
|
|
22
|
+
byte_messages.each { |bytes| ensure_sysex_permitted!(bytes) }
|
|
23
|
+
|
|
24
|
+
if timestamp && timestamp > current_timestamp
|
|
25
|
+
schedule(byte_messages, timestamp)
|
|
26
|
+
else
|
|
27
|
+
byte_messages.each { |bytes| write_bytes(bytes) }
|
|
28
|
+
end
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def clear
|
|
33
|
+
@scheduler_mutex.synchronize do
|
|
34
|
+
@scheduled_messages.clear
|
|
35
|
+
@scheduler_cv.broadcast
|
|
36
|
+
end
|
|
37
|
+
self
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def note_on(note, velocity: 100, channel: 0)
|
|
41
|
+
send(Message.note_on(note, velocity: velocity, channel: channel))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def note_off(note, velocity: 0, channel: 0)
|
|
45
|
+
send(Message.note_off(note, velocity: velocity, channel: channel))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def control_change(cc, value, channel: 0)
|
|
49
|
+
send(Message.control_change(cc, value, channel: channel))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def program_change(program, channel: 0)
|
|
53
|
+
send(Message.program_change(program, channel: channel))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def pitch_bend(value, channel: 0)
|
|
57
|
+
send(Message.pitch_bend(value, channel: channel))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def all_notes_off(channel: nil)
|
|
61
|
+
if channel
|
|
62
|
+
send(Message.control_change(Message::Channel::ControlChange::ALL_NOTES_OFF, 0, channel: channel))
|
|
63
|
+
else
|
|
64
|
+
16.times { |ch| send(Message.control_change(Message::Channel::ControlChange::ALL_NOTES_OFF, 0, channel: ch)) }
|
|
65
|
+
end
|
|
66
|
+
self
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def reset
|
|
70
|
+
send(Message.system_reset)
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def <<(message)
|
|
75
|
+
send(message)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def use(middleware = nil, **options, &block)
|
|
79
|
+
require_relative "../middleware"
|
|
80
|
+
|
|
81
|
+
@middleware_stack ||= Middleware::Stack.new
|
|
82
|
+
if middleware.is_a?(Middleware::Stack) && options.empty? && !block
|
|
83
|
+
@middleware_stack = middleware
|
|
84
|
+
else
|
|
85
|
+
@middleware_stack.use(middleware || block, **options)
|
|
86
|
+
end
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def send_all(*messages)
|
|
91
|
+
items = if messages.size == 1 && messages.first.is_a?(Array) &&
|
|
92
|
+
messages.first.all? { |message| message.is_a?(Message::Base) }
|
|
93
|
+
messages.first
|
|
94
|
+
else
|
|
95
|
+
messages
|
|
96
|
+
end
|
|
97
|
+
items.each { |msg| send(msg) }
|
|
98
|
+
self
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def close
|
|
102
|
+
shutdown_scheduler
|
|
103
|
+
super
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def disconnect
|
|
107
|
+
shutdown_scheduler
|
|
108
|
+
super
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def apply_middleware(message)
|
|
114
|
+
return message unless @middleware_stack
|
|
115
|
+
|
|
116
|
+
@middleware_stack.call(message)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def outbound_byte_messages(message)
|
|
120
|
+
case message
|
|
121
|
+
when Message::Base
|
|
122
|
+
[message.to_bytes]
|
|
123
|
+
when Array
|
|
124
|
+
return Message.parse_many(message, normalize_note_on_zero: false).map(&:to_bytes) if message.all?(Integer)
|
|
125
|
+
|
|
126
|
+
message.compact.flat_map { |item| outbound_byte_messages(item) }
|
|
127
|
+
else
|
|
128
|
+
raise InvalidMessageError, "Expected Message or byte Array, got #{message.class}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def ensure_sysex_permitted!(bytes)
|
|
133
|
+
return unless bytes[0] == 0xF0
|
|
134
|
+
return if sysex_enabled?
|
|
135
|
+
|
|
136
|
+
raise SysExNotPermittedError, "System exclusive messages require sysex: true"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def schedule(byte_messages, timestamp)
|
|
140
|
+
@scheduler_mutex.synchronize do
|
|
141
|
+
byte_messages.each { |bytes| @scheduled_messages << [timestamp, bytes] }
|
|
142
|
+
@scheduled_messages.sort_by!(&:first)
|
|
143
|
+
start_scheduler_locked
|
|
144
|
+
@scheduler_cv.broadcast
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def start_scheduler_locked
|
|
149
|
+
return if @scheduler_thread&.alive?
|
|
150
|
+
|
|
151
|
+
@scheduler_shutdown = false
|
|
152
|
+
@scheduler_thread = Thread.new { scheduler_loop }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def scheduler_loop
|
|
156
|
+
loop do
|
|
157
|
+
item = next_scheduled_item
|
|
158
|
+
break unless item
|
|
159
|
+
|
|
160
|
+
write_bytes(item[1])
|
|
161
|
+
rescue PortClosedError
|
|
162
|
+
break
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def next_scheduled_item
|
|
167
|
+
@scheduler_mutex.synchronize do
|
|
168
|
+
loop do
|
|
169
|
+
return nil if @scheduler_shutdown
|
|
170
|
+
|
|
171
|
+
if @scheduled_messages.empty?
|
|
172
|
+
@scheduler_cv.wait(@scheduler_mutex)
|
|
173
|
+
next
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
timestamp, bytes = @scheduled_messages.first
|
|
177
|
+
delay = timestamp - current_timestamp
|
|
178
|
+
if delay.positive?
|
|
179
|
+
@scheduler_cv.wait(@scheduler_mutex, delay)
|
|
180
|
+
next
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
@scheduled_messages.shift
|
|
184
|
+
return [timestamp, bytes]
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def write_bytes(bytes)
|
|
190
|
+
@transport_handle.write(bytes)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def current_timestamp
|
|
194
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def shutdown_scheduler
|
|
198
|
+
thread = @scheduler_mutex.synchronize do
|
|
199
|
+
@scheduled_messages.clear
|
|
200
|
+
@scheduler_shutdown = true
|
|
201
|
+
@scheduler_cv.broadcast
|
|
202
|
+
@scheduler_thread
|
|
203
|
+
end
|
|
204
|
+
thread&.join
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
data/lib/webmidi/port.rb
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module SMF
|
|
5
|
+
class Event
|
|
6
|
+
attr_reader :delta_time, :absolute_time
|
|
7
|
+
|
|
8
|
+
def initialize(delta_time: 0, absolute_time: 0)
|
|
9
|
+
validate_time!(delta_time, "Delta time")
|
|
10
|
+
validate_time!(absolute_time, "Absolute time")
|
|
11
|
+
@delta_time = delta_time
|
|
12
|
+
@absolute_time = absolute_time
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def absolute_time=(time)
|
|
16
|
+
validate_time!(time, "Absolute time")
|
|
17
|
+
@absolute_time = time
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def delta_time=(time)
|
|
21
|
+
validate_time!(time, "Delta time")
|
|
22
|
+
@delta_time = time
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def validate_time!(time, name)
|
|
28
|
+
return if time.is_a?(Integer) && time >= 0
|
|
29
|
+
|
|
30
|
+
raise InvalidSMFError, "#{name} must be a non-negative integer, got #{time.inspect}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class MIDIEvent < Event
|
|
35
|
+
attr_reader :message
|
|
36
|
+
|
|
37
|
+
def initialize(message:, **kwargs)
|
|
38
|
+
super(**kwargs)
|
|
39
|
+
@message = message
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_bytes
|
|
43
|
+
@message.to_bytes
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class MetaEvent < Event
|
|
48
|
+
attr_reader :type, :data
|
|
49
|
+
|
|
50
|
+
META_TYPES = {
|
|
51
|
+
sequence_number: 0x00,
|
|
52
|
+
text: 0x01,
|
|
53
|
+
copyright: 0x02,
|
|
54
|
+
track_name: 0x03,
|
|
55
|
+
instrument_name: 0x04,
|
|
56
|
+
lyric: 0x05,
|
|
57
|
+
marker: 0x06,
|
|
58
|
+
cue_point: 0x07,
|
|
59
|
+
channel_prefix: 0x20,
|
|
60
|
+
end_of_track: 0x2F,
|
|
61
|
+
tempo: 0x51,
|
|
62
|
+
smpte_offset: 0x54,
|
|
63
|
+
time_signature: 0x58,
|
|
64
|
+
key_signature: 0x59,
|
|
65
|
+
sequencer_specific: 0x7F
|
|
66
|
+
}.freeze
|
|
67
|
+
|
|
68
|
+
def initialize(type:, data: [], **kwargs)
|
|
69
|
+
super(**kwargs)
|
|
70
|
+
validate_type!(type)
|
|
71
|
+
validate_data!(data)
|
|
72
|
+
@type = type
|
|
73
|
+
@data = data.frozen? ? data : data.dup.freeze
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def text(encoding: Encoding::UTF_8)
|
|
77
|
+
return nil unless text_event?
|
|
78
|
+
|
|
79
|
+
@data.pack("C*").force_encoding(encoding)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def text_event?
|
|
83
|
+
@type.between?(0x01, 0x07)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def tempo
|
|
87
|
+
return nil unless @type == META_TYPES[:tempo]
|
|
88
|
+
return nil unless @data.size == 3
|
|
89
|
+
|
|
90
|
+
(@data[0] << 16) | (@data[1] << 8) | @data[2]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def bpm
|
|
94
|
+
t = tempo
|
|
95
|
+
return nil unless t
|
|
96
|
+
|
|
97
|
+
60_000_000.0 / t
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.tempo(bpm, **kwargs)
|
|
101
|
+
raise InvalidSMFError, "Tempo BPM must be positive, got #{bpm.inspect}" unless bpm.is_a?(Numeric) && bpm.positive?
|
|
102
|
+
|
|
103
|
+
microseconds = (60_000_000.0 / bpm).round
|
|
104
|
+
data = [
|
|
105
|
+
(microseconds >> 16) & 0xFF,
|
|
106
|
+
(microseconds >> 8) & 0xFF,
|
|
107
|
+
microseconds & 0xFF
|
|
108
|
+
]
|
|
109
|
+
new(type: META_TYPES[:tempo], data: data, **kwargs)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.text(value, type: :text, encoding: Encoding::UTF_8, **kwargs)
|
|
113
|
+
meta_type = type.is_a?(Symbol) ? META_TYPES.fetch(type) : type
|
|
114
|
+
new(type: meta_type, data: value.encode(encoding).bytes, **kwargs)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.track_name(name, encoding: Encoding::UTF_8, **kwargs)
|
|
118
|
+
text(name, type: :track_name, encoding: encoding, **kwargs)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.end_of_track(**kwargs)
|
|
122
|
+
new(type: META_TYPES[:end_of_track], data: [], **kwargs)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def self.time_signature(numerator: 4, denominator: 4, clocks_per_click: 24, notes_per_quarter: 8, **kwargs)
|
|
126
|
+
unless numerator.is_a?(Integer) && numerator.positive?
|
|
127
|
+
raise InvalidSMFError, "Time signature numerator must be positive, got #{numerator.inspect}"
|
|
128
|
+
end
|
|
129
|
+
unless denominator.is_a?(Integer) && denominator.positive? && (denominator & (denominator - 1)).zero?
|
|
130
|
+
raise InvalidSMFError, "Time signature denominator must be a power of two, got #{denominator.inspect}"
|
|
131
|
+
end
|
|
132
|
+
[clocks_per_click, notes_per_quarter].each do |value|
|
|
133
|
+
raise InvalidSMFError, "Time signature values must be bytes" unless value.is_a?(Integer) && value.between?(0, 255)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
dd = Math.log2(denominator).to_i
|
|
137
|
+
new(type: META_TYPES[:time_signature], data: [numerator, dd, clocks_per_click, notes_per_quarter], **kwargs)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def self.key_signature(key: 0, scale: 0, **kwargs)
|
|
141
|
+
raise InvalidSMFError, "Key signature must be between -7 and 7, got #{key.inspect}" unless key.is_a?(Integer) && key.between?(-7, 7)
|
|
142
|
+
|
|
143
|
+
scale = case scale
|
|
144
|
+
when :major then 0
|
|
145
|
+
when :minor then 1
|
|
146
|
+
else scale
|
|
147
|
+
end
|
|
148
|
+
raise InvalidSMFError, "Key signature scale must be 0/:major or 1/:minor" unless [0, 1].include?(scale)
|
|
149
|
+
|
|
150
|
+
sf = (key < 0) ? (256 + key) : key
|
|
151
|
+
new(type: META_TYPES[:key_signature], data: [sf, scale], **kwargs)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def validate_type!(type)
|
|
157
|
+
return if type.is_a?(Integer) && type.between?(0, 127)
|
|
158
|
+
|
|
159
|
+
raise InvalidSMFError, "Meta event type must be between 0 and 127, got #{type.inspect}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def validate_data!(data)
|
|
163
|
+
unless data.respond_to?(:each)
|
|
164
|
+
raise InvalidSMFError, "Meta event data must be enumerable, got #{data.class}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
data.each_with_index do |byte, index|
|
|
168
|
+
next if byte.is_a?(Integer) && byte.between?(0, 255)
|
|
169
|
+
|
|
170
|
+
raise InvalidSMFError, "Meta event data byte #{index} must be between 0 and 255, got #{byte.inspect}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
class SysExEvent < Event
|
|
176
|
+
attr_reader :data
|
|
177
|
+
|
|
178
|
+
def initialize(data:, **kwargs)
|
|
179
|
+
super(**kwargs)
|
|
180
|
+
bytes = normalize_data(data)
|
|
181
|
+
@data = bytes.frozen? ? bytes : bytes.dup.freeze
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def to_bytes
|
|
185
|
+
(@data.last == 0xF7) ? [0xF0, *@data] : [0xF0, *@data, 0xF7]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
private
|
|
189
|
+
|
|
190
|
+
def normalize_data(data)
|
|
191
|
+
unless data.respond_to?(:each)
|
|
192
|
+
raise InvalidSMFError, "SysEx event data must be enumerable, got #{data.class}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
bytes = data.to_a
|
|
196
|
+
bytes = bytes[1..] if bytes.first == 0xF0
|
|
197
|
+
bytes.each_with_index do |byte, index|
|
|
198
|
+
next if byte.is_a?(Integer) && byte.between?(0, 255)
|
|
199
|
+
|
|
200
|
+
raise InvalidSMFError, "SysEx event data byte #{index} must be between 0 and 255, got #{byte.inspect}"
|
|
201
|
+
end
|
|
202
|
+
bytes
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|