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,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "securerandom"
5
+
6
+ module Webmidi
7
+ module Network
8
+ module AppleMIDI
9
+ module_function
10
+
11
+ def server(port: 5004, name: "Webmidi")
12
+ Session.new(port: port, name: name, mode: :server)
13
+ end
14
+
15
+ def connect(host, port: 5004, name: "Webmidi Client")
16
+ session = Session.new(port: 0, name: name, mode: :client)
17
+ session.connect_to(host, port)
18
+ session
19
+ end
20
+
21
+ class Session
22
+ attr_reader :name, :control_port, :data_port, :ssrc
23
+
24
+ def initialize(port:, name:, mode: :server)
25
+ @requested_control_port = port
26
+ @requested_data_port = port.zero? ? 0 : port + 1
27
+ @name = name
28
+ @mode = mode
29
+ @ssrc = SecureRandom.random_number(0xFFFFFFFF)
30
+ @rtp = RTP::Session.new(port: @requested_data_port, name: name, mode: mode, ssrc: @ssrc)
31
+ @pending_tokens = {}
32
+ @control_peers = []
33
+ @mutex = Mutex.new
34
+ @running = false
35
+ @control_socket = nil
36
+ @control_thread = nil
37
+ @data_subscription = nil
38
+ end
39
+
40
+ def start
41
+ return self if @running
42
+
43
+ @rtp.start
44
+ @data_port = @rtp.port
45
+ @data_subscription = @rtp.on_control_packet { |packet, peer| handle_control_packet(packet, peer, :data) }
46
+ @control_socket = UDPSocket.new
47
+ @control_socket.bind("0.0.0.0", @requested_control_port)
48
+ @control_port = @control_socket.addr[1]
49
+ @running = true
50
+ @control_thread = Thread.new { control_loop }
51
+ self
52
+ end
53
+
54
+ def stop
55
+ @running = false
56
+ @control_socket&.close
57
+ @control_thread&.join(1)
58
+ @data_subscription&.unsubscribe
59
+ @rtp.stop
60
+ self
61
+ end
62
+
63
+ alias_method :close, :stop
64
+
65
+ def connect_to(host, port, data_port: port + 1)
66
+ start unless @running
67
+
68
+ token = SecureRandom.random_number(0xFFFF_FFFF)
69
+ peer = {host: host, control_port: port, data_port: data_port}
70
+ @mutex.synchronize { @pending_tokens[token] = peer }
71
+ send_control(RTP::ControlPacket.invitation(token: token, ssrc: @ssrc, name: @name), host, port)
72
+ self
73
+ end
74
+
75
+ def send(message)
76
+ @rtp.send(message)
77
+ end
78
+
79
+ def on_message(&block)
80
+ @rtp.on_message(&block)
81
+ end
82
+
83
+ def on_error(&block)
84
+ @rtp.on_error(&block)
85
+ end
86
+
87
+ def peers
88
+ @rtp.peers
89
+ end
90
+
91
+ private
92
+
93
+ def control_loop
94
+ while @running
95
+ begin
96
+ data, address = @control_socket.recvfrom_nonblock(1024)
97
+ packet = RTP::ControlPacket.parse(data)
98
+ handle_control_packet(packet, {host: address[3], port: address[1]}, :control) if packet
99
+ rescue IO::WaitReadable
100
+ break unless @running
101
+
102
+ begin
103
+ IO.select([@control_socket], nil, nil, 0.1)
104
+ rescue IOError, SystemCallError
105
+ break
106
+ end
107
+ rescue IOError, SystemCallError
108
+ break
109
+ rescue => e
110
+ notify_error(e, data)
111
+ end
112
+ end
113
+ end
114
+
115
+ def handle_control_packet(packet, peer, channel)
116
+ case packet.command
117
+ when :invitation
118
+ accept_invitation(packet, peer, channel)
119
+ when :accepted
120
+ accept_response(packet, peer, channel)
121
+ when :rejected
122
+ @mutex.synchronize { @pending_tokens.delete(packet.token) }
123
+ when :synchronization
124
+ reply_to_synchronization(packet, peer, channel)
125
+ when :end_session
126
+ @rtp.remove_peer(peer[:host], peer[:port])
127
+ end
128
+ end
129
+
130
+ def accept_invitation(packet, peer, channel)
131
+ response = RTP::ControlPacket.accepted(token: packet.token, ssrc: @ssrc, name: @name)
132
+ send_packet(response, peer[:host], peer[:port], channel)
133
+
134
+ if channel == :data
135
+ @rtp.add_peer(peer[:host], peer[:port])
136
+ else
137
+ @mutex.synchronize { @control_peers << peer unless @control_peers.include?(peer) }
138
+ end
139
+ end
140
+
141
+ def accept_response(packet, peer, channel)
142
+ pending = @mutex.synchronize { @pending_tokens[packet.token] }
143
+ return unless pending
144
+
145
+ if channel == :control
146
+ send_data_invitation(packet.token, pending[:host], pending[:data_port])
147
+ else
148
+ @rtp.add_peer(peer[:host], peer[:port])
149
+ @mutex.synchronize { @pending_tokens.delete(packet.token) }
150
+ end
151
+ end
152
+
153
+ def send_data_invitation(token, host, port)
154
+ invitation = RTP::ControlPacket.invitation(token: token, ssrc: @ssrc, name: @name)
155
+ @rtp.send_control_packet(invitation, host, port)
156
+ end
157
+
158
+ def reply_to_synchronization(packet, peer, channel)
159
+ return if packet.count >= 3
160
+
161
+ timestamps = packet.timestamps.dup
162
+ timestamps[packet.count] = RTP::ControlPacket.timestamp
163
+ response = RTP::ControlPacket.synchronization(
164
+ ssrc: @ssrc,
165
+ count: packet.count + 1,
166
+ timestamps: timestamps
167
+ )
168
+ send_packet(response, peer[:host], peer[:port], channel)
169
+ end
170
+
171
+ def send_packet(packet, host, port, channel)
172
+ if channel == :data
173
+ @rtp.send_control_packet(packet, host, port)
174
+ else
175
+ send_control(packet, host, port)
176
+ end
177
+ end
178
+
179
+ def send_control(packet, host, port)
180
+ @control_socket&.send(packet.to_bytes, 0, host, port)
181
+ end
182
+
183
+ def notify_error(error, data = nil)
184
+ @rtp.send(:notify_error, error, data)
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require_relative "../callback_subscription"
5
+
6
+ module Webmidi
7
+ module Network
8
+ module OSC
9
+ DEFAULT_MAPPINGS = {
10
+ Webmidi::Message::Channel::NoteOn => "/midi/note/on",
11
+ Webmidi::Message::Channel::NoteOff => "/midi/note/off",
12
+ Webmidi::Message::Channel::ControlChange => "/midi/cc",
13
+ Webmidi::Message::Channel::ProgramChange => "/midi/program",
14
+ Webmidi::Message::Channel::PitchBend => "/midi/pitch_bend",
15
+ Webmidi::Message::Channel::ChannelPressure => "/midi/pressure",
16
+ Webmidi::Message::Channel::PolyphonicPressure => "/midi/poly_pressure"
17
+ }.freeze
18
+
19
+ DEFAULT_REVERSE_MAPPINGS = {
20
+ "/midi/note/on" => ->(args) { Message.note_on(args[1], velocity: args[2], channel: args[0]) },
21
+ "/midi/note/off" => ->(args) { Message.note_off(args[1], velocity: args[2], channel: args[0]) },
22
+ "/midi/cc" => ->(args) { Message.control_change(args[1], args[2], channel: args[0]) },
23
+ "/midi/program" => ->(args) { Message.program_change(args[1], channel: args[0]) },
24
+ "/midi/pitch_bend" => ->(args) { Message.pitch_bend(args[1], channel: args[0]) },
25
+ "/midi/pressure" => ->(args) { Message.channel_pressure(args[1], channel: args[0]) },
26
+ "/midi/poly_pressure" => ->(args) { Message.polyphonic_pressure(args[1], args[2], channel: args[0]) }
27
+ }.freeze
28
+
29
+ module_function
30
+
31
+ def bridge(midi_input: nil, osc_host: "127.0.0.1", osc_port: 9000, mapping: :default)
32
+ Bridge.new(midi_input: midi_input, osc_host: osc_host, osc_port: osc_port, mapping: mapping)
33
+ end
34
+
35
+ module Encoder
36
+ module_function
37
+
38
+ def encode_message(address, *args)
39
+ data = encode_string(address)
40
+ type_tag = ","
41
+ args_data = String.new(encoding: Encoding::ASCII_8BIT)
42
+
43
+ args.each do |arg|
44
+ case arg
45
+ when Integer
46
+ type_tag += "i"
47
+ args_data += [arg].pack("N")
48
+ when Float
49
+ type_tag += "f"
50
+ args_data += [arg].pack("g")
51
+ when String
52
+ type_tag += "s"
53
+ args_data += encode_string(arg)
54
+ end
55
+ end
56
+
57
+ data + encode_string(type_tag) + args_data
58
+ end
59
+
60
+ def encode_string(str)
61
+ padded = str + "\0"
62
+ padded += "\0" until (padded.bytesize % 4).zero?
63
+ padded.b
64
+ end
65
+
66
+ def decode_message(data)
67
+ address, offset = decode_string(data, 0)
68
+ type_tag, offset = decode_string(data, offset)
69
+ raise InvalidMessageError, "OSC type tag must start with comma" unless type_tag.start_with?(",")
70
+
71
+ type_tag = type_tag[1..]
72
+
73
+ args = []
74
+ type_tag.each_char do |t|
75
+ case t
76
+ when "i"
77
+ ensure_available!(data, offset, 4)
78
+ args << data[offset, 4].unpack1("N")
79
+ offset += 4
80
+ when "f"
81
+ ensure_available!(data, offset, 4)
82
+ args << data[offset, 4].unpack1("g")
83
+ offset += 4
84
+ when "s"
85
+ str, offset = decode_string(data, offset)
86
+ args << str
87
+ else
88
+ raise InvalidMessageError, "Unsupported OSC argument type: #{t.inspect}"
89
+ end
90
+ end
91
+
92
+ [address, args]
93
+ end
94
+
95
+ def decode_string(data, offset)
96
+ raise InvalidMessageError, "OSC string offset out of bounds" if offset >= data.bytesize
97
+
98
+ null_pos = data.index("\0", offset)
99
+ raise InvalidMessageError, "OSC string missing null terminator" unless null_pos
100
+
101
+ str = data[offset...null_pos]
102
+ new_offset = null_pos + 1
103
+ new_offset += 1 until (new_offset % 4).zero?
104
+ raise InvalidMessageError, "OSC string padding exceeds packet length" if new_offset > data.bytesize
105
+
106
+ [str, new_offset]
107
+ end
108
+
109
+ def ensure_available!(data, offset, length)
110
+ return if offset + length <= data.bytesize
111
+
112
+ raise InvalidMessageError, "OSC packet ended while reading argument"
113
+ end
114
+
115
+ private_class_method :ensure_available!
116
+ end
117
+
118
+ class Bridge
119
+ attr_reader :mapping, :reverse_mapping
120
+
121
+ def initialize(midi_input: nil, midi_output: nil, osc_host: "127.0.0.1", osc_port: 9000, mapping: :default,
122
+ reverse_mapping: :default)
123
+ @midi_input = midi_input
124
+ @midi_output = midi_output
125
+ @osc_host = osc_host
126
+ @osc_port = osc_port
127
+ @mapping = (mapping == :default) ? DEFAULT_MAPPINGS.dup : mapping
128
+ @reverse_mapping = (reverse_mapping == :default) ? DEFAULT_REVERSE_MAPPINGS.dup : reverse_mapping
129
+ @socket = nil
130
+ @running = false
131
+ @subscription = nil
132
+ end
133
+
134
+ def start
135
+ return self if @running
136
+
137
+ @socket = UDPSocket.new
138
+ @running = true
139
+ @subscription = @midi_input&.on_message { |msg| send_osc(msg) }
140
+ self
141
+ end
142
+
143
+ def stop
144
+ @running = false
145
+ @subscription&.unsubscribe
146
+ @subscription = nil
147
+ @socket&.close
148
+ @socket = nil
149
+ self
150
+ end
151
+
152
+ def send_osc(message)
153
+ return unless @running && @socket
154
+
155
+ address = @mapping[message.class]
156
+ return unless address
157
+
158
+ args = midi_to_osc_args(message)
159
+ data = Encoder.encode_message(address, *args)
160
+ @socket.send(data, 0, @osc_host, @osc_port)
161
+ end
162
+
163
+ def receive_osc(data)
164
+ address, args = Encoder.decode_message(data)
165
+ message = osc_to_midi(address, args)
166
+ @midi_output&.send(message) if message
167
+ message
168
+ end
169
+
170
+ def osc_to_midi(address, args)
171
+ mapper = @reverse_mapping[address]
172
+ mapper&.call(args)
173
+ end
174
+
175
+ def custom_mapping(&block)
176
+ block.call(@mapping)
177
+ self
178
+ end
179
+
180
+ private
181
+
182
+ def midi_to_osc_args(message)
183
+ case message
184
+ when Message::Channel::NoteOn
185
+ [message.channel, message.note, message.velocity]
186
+ when Message::Channel::NoteOff
187
+ [message.channel, message.note, message.velocity]
188
+ when Message::Channel::ControlChange
189
+ [message.channel, message.cc, message.value]
190
+ when Message::Channel::ProgramChange
191
+ [message.channel, message.program]
192
+ when Message::Channel::PitchBend
193
+ [message.channel, message.value]
194
+ when Message::Channel::ChannelPressure
195
+ [message.channel, message.pressure]
196
+ when Message::Channel::PolyphonicPressure
197
+ [message.channel, message.note, message.pressure]
198
+ else
199
+ message.to_bytes
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end