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