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,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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "port/base"
4
+ require_relative "port/input"
5
+ require_relative "port/output"
6
+ require_relative "port/map"
7
+
8
+ module Webmidi
9
+ module Port
10
+ end
11
+ end
@@ -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