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,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Webmidi
6
+ module SMF
7
+ module Writer
8
+ module_function
9
+
10
+ def write(sequence, path_or_io, **options)
11
+ binary = to_binary(sequence, **options)
12
+ if path_or_io.respond_to?(:write)
13
+ path_or_io.write(binary)
14
+ else
15
+ File.binwrite(path_or_io, binary)
16
+ end
17
+ end
18
+
19
+ def to_binary(sequence, running_status: false)
20
+ validate_sequence!(sequence)
21
+ out = StringIO.new(String.new(encoding: Encoding::ASCII_8BIT))
22
+ write_header(out, sequence)
23
+ sequence.each { |track| write_track(out, track, running_status: running_status) }
24
+ out.string
25
+ end
26
+
27
+ def write_header(out, sequence)
28
+ out.write("MThd")
29
+ write_uint32(out, 6)
30
+ write_uint16(out, sequence.format)
31
+ write_uint16(out, sequence.size)
32
+ write_uint16(out, sequence.ppqn)
33
+ end
34
+
35
+ def write_track(out, track, running_status: false)
36
+ track_data = StringIO.new(String.new(encoding: Encoding::ASCII_8BIT))
37
+ has_end_of_track = false
38
+ last_status = nil
39
+
40
+ track.each do |event|
41
+ write_vlq(track_data, event.delta_time)
42
+
43
+ case event
44
+ when MetaEvent
45
+ track_data.putc(0xFF)
46
+ track_data.putc(event.type)
47
+ write_vlq(track_data, event.data.size)
48
+ event.data.each { |b| track_data.putc(b) }
49
+ has_end_of_track = true if event.type == MetaEvent::META_TYPES[:end_of_track]
50
+ last_status = nil
51
+ when SysExEvent
52
+ data = (event.data.last == 0xF7) ? event.data : [*event.data, 0xF7]
53
+ track_data.putc(0xF0)
54
+ write_vlq(track_data, data.size)
55
+ data.each { |b| track_data.putc(b) }
56
+ last_status = nil
57
+ when MIDIEvent
58
+ bytes = event.to_bytes
59
+ if running_status && channel_status?(bytes[0]) && last_status == bytes[0]
60
+ bytes[1..].each { |b| track_data.putc(b) }
61
+ else
62
+ bytes.each { |b| track_data.putc(b) }
63
+ last_status = channel_status?(bytes[0]) ? bytes[0] : nil
64
+ end
65
+ end
66
+ end
67
+
68
+ unless has_end_of_track
69
+ write_vlq(track_data, 0)
70
+ track_data.putc(0xFF)
71
+ track_data.putc(0x2F)
72
+ write_vlq(track_data, 0)
73
+ end
74
+
75
+ track_bytes = track_data.string
76
+ out.write("MTrk")
77
+ write_uint32(out, track_bytes.bytesize)
78
+ out.write(track_bytes)
79
+ end
80
+
81
+ def write_uint16(out, value)
82
+ out.putc((value >> 8) & 0xFF)
83
+ out.putc(value & 0xFF)
84
+ end
85
+
86
+ def write_uint32(out, value)
87
+ out.putc((value >> 24) & 0xFF)
88
+ out.putc((value >> 16) & 0xFF)
89
+ out.putc((value >> 8) & 0xFF)
90
+ out.putc(value & 0xFF)
91
+ end
92
+
93
+ def write_vlq(out, value)
94
+ unless value.is_a?(Integer) && value.between?(0, 0x0FFF_FFFF)
95
+ raise InvalidSMFError, "VLQ value must be between 0 and 0x0FFFFFFF, got #{value.inspect}"
96
+ end
97
+
98
+ bytes = [value & 0x7F]
99
+ value >>= 7
100
+ while value > 0
101
+ bytes.unshift((value & 0x7F) | 0x80)
102
+ value >>= 7
103
+ end
104
+ bytes.each { |b| out.putc(b) }
105
+ end
106
+
107
+ def validate_sequence!(sequence)
108
+ if sequence.format.zero? && sequence.size != 1
109
+ raise InvalidSMFError, "SMF format 0 must contain exactly one track"
110
+ end
111
+ end
112
+
113
+ def channel_status?(status)
114
+ status < 0xF0
115
+ end
116
+
117
+ private_class_method :write_header, :write_track, :write_uint16, :write_uint32, :write_vlq,
118
+ :validate_sequence!, :channel_status?
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "smf/event"
4
+ require_relative "smf/track"
5
+ require_relative "smf/tempo_map"
6
+ require_relative "smf/sequence"
7
+ require_relative "smf/reader"
8
+ require_relative "smf/writer"
9
+
10
+ module Webmidi
11
+ module SMF
12
+ end
13
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webmidi
4
+ module Transport
5
+ module Adapter
6
+ REQUIRED_METHODS = %i[
7
+ available?
8
+ list_inputs
9
+ list_outputs
10
+ open_input
11
+ open_output
12
+ ].freeze
13
+
14
+ module_function
15
+
16
+ def validate!(transport)
17
+ missing = REQUIRED_METHODS.reject { |method| transport.respond_to?(method) }
18
+ return transport if missing.empty?
19
+
20
+ raise TransportNotAvailableError,
21
+ "Transport adapter #{transport.inspect} is missing: #{missing.join(", ")}"
22
+ end
23
+
24
+ def gem_name(name)
25
+ "webmidi-#{normalized_name(name).tr("_", "-")}"
26
+ end
27
+
28
+ def require_path(name)
29
+ "webmidi/transport/#{normalized_name(name)}"
30
+ end
31
+
32
+ def constant_name(name)
33
+ camel = normalized_name(name).split("_").map(&:capitalize).join
34
+ "Webmidi::Transport::#{camel}"
35
+ end
36
+
37
+ def constantize(path)
38
+ path.split("::").reduce(Object) { |namespace, const_name| namespace.const_get(const_name) }
39
+ end
40
+
41
+ def normalized_name(name)
42
+ name.to_s.tr("-", "_")
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webmidi
4
+ module Transport
5
+ class Base
6
+ def self.available?
7
+ false
8
+ end
9
+
10
+ def self.list_inputs
11
+ raise NotImplementedError
12
+ end
13
+
14
+ def self.list_outputs
15
+ raise NotImplementedError
16
+ end
17
+
18
+ def self.open_input(device_info)
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def self.open_output(device_info)
23
+ raise NotImplementedError
24
+ end
25
+
26
+ def self.create_virtual_input(name)
27
+ raise NotImplementedError
28
+ end
29
+
30
+ def self.create_virtual_output(name)
31
+ raise NotImplementedError
32
+ end
33
+ end
34
+
35
+ module InputHandle
36
+ def read(timeout: nil)
37
+ raise NotImplementedError
38
+ end
39
+
40
+ def on_data(&block)
41
+ raise NotImplementedError
42
+ end
43
+
44
+ def close
45
+ raise NotImplementedError
46
+ end
47
+ end
48
+
49
+ module OutputHandle
50
+ def write(bytes)
51
+ raise NotImplementedError
52
+ end
53
+
54
+ def close
55
+ raise NotImplementedError
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webmidi
4
+ module Transport
5
+ DeviceInfo = Data.define(:id, :name, :manufacturer, :version)
6
+ end
7
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Webmidi
6
+ module Transport
7
+ class Null < Base
8
+ def self.available?
9
+ true
10
+ end
11
+
12
+ def self.list_inputs
13
+ []
14
+ end
15
+
16
+ def self.list_outputs
17
+ []
18
+ end
19
+
20
+ def self.create_virtual_input(name)
21
+ info = DeviceInfo.new(id: generate_id("input"), name: name, manufacturer: "Null", version: "0")
22
+ NullInputHandle.new(info)
23
+ end
24
+
25
+ def self.create_virtual_output(name)
26
+ info = DeviceInfo.new(id: generate_id("output"), name: name, manufacturer: "Null", version: "0")
27
+ NullOutputHandle.new(info)
28
+ end
29
+
30
+ def self.generate_id(type)
31
+ "null-#{type}-#{SecureRandom.uuid}"
32
+ end
33
+ private_class_method :generate_id
34
+
35
+ class NullInputHandle
36
+ include InputHandle
37
+
38
+ attr_reader :device_info
39
+
40
+ def initialize(device_info)
41
+ @device_info = device_info
42
+ end
43
+
44
+ def read(timeout: nil)
45
+ nil
46
+ end
47
+
48
+ def on_data(&block)
49
+ # no-op
50
+ end
51
+
52
+ def close
53
+ # no-op
54
+ end
55
+ end
56
+
57
+ class NullOutputHandle
58
+ include OutputHandle
59
+
60
+ attr_reader :device_info
61
+
62
+ def initialize(device_info)
63
+ @device_info = device_info
64
+ @sent_messages = []
65
+ end
66
+
67
+ def write(bytes)
68
+ @sent_messages << bytes
69
+ end
70
+
71
+ def sent_messages
72
+ @sent_messages.dup
73
+ end
74
+
75
+ def close
76
+ # no-op
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Webmidi
6
+ module Transport
7
+ class Virtual < Base
8
+ @ports = {}
9
+ @mutex = Mutex.new
10
+
11
+ def self.available?
12
+ true
13
+ end
14
+
15
+ def self.list_inputs
16
+ @mutex.synchronize { @ports.values.select { |p| p.is_a?(VirtualInputHandle) && !p.closed? }.map(&:device_info) }
17
+ end
18
+
19
+ def self.list_outputs
20
+ @mutex.synchronize { @ports.values.select { |p| p.is_a?(VirtualOutputHandle) && !p.closed? }.map(&:device_info) }
21
+ end
22
+
23
+ def self.create_virtual_input(name)
24
+ info = DeviceInfo.new(id: generate_id, name: name, manufacturer: "Webmidi Virtual", version: "1.0")
25
+ handle = VirtualInputHandle.new(info, on_close: -> { unregister(info.id) })
26
+ @mutex.synchronize { @ports[info.id] = handle }
27
+ handle
28
+ end
29
+
30
+ def self.create_virtual_output(name)
31
+ info = DeviceInfo.new(id: generate_id, name: name, manufacturer: "Webmidi Virtual", version: "1.0")
32
+ handle = VirtualOutputHandle.new(info, on_close: -> { unregister(info.id) })
33
+ @mutex.synchronize { @ports[info.id] = handle }
34
+ handle
35
+ end
36
+
37
+ def self.open_input(device_info)
38
+ handle = find_handle(device_info.id, VirtualInputHandle)
39
+ return handle if handle
40
+
41
+ raise PortNotFoundError, "Virtual input not found: #{device_info.id}"
42
+ end
43
+
44
+ def self.open_output(device_info)
45
+ handle = find_handle(device_info.id, VirtualOutputHandle)
46
+ return handle if handle
47
+
48
+ raise PortNotFoundError, "Virtual output not found: #{device_info.id}"
49
+ end
50
+
51
+ def self.create_loopback(name)
52
+ input = create_virtual_input(name)
53
+ output = create_virtual_output(name)
54
+ output.connect(input)
55
+ [input, output]
56
+ end
57
+
58
+ def self.reset!
59
+ handles = @mutex.synchronize { @ports.values.dup }
60
+ handles.each(&:close)
61
+ @mutex.synchronize { @ports.clear }
62
+ end
63
+
64
+ def self.generate_id
65
+ "virtual-#{SecureRandom.uuid}"
66
+ end
67
+
68
+ def self.unregister(id)
69
+ @mutex.synchronize { @ports.delete(id) }
70
+ end
71
+
72
+ def self.find_handle(id, handle_class)
73
+ @mutex.synchronize do
74
+ handle = @ports[id]
75
+ handle if handle.is_a?(handle_class) && !handle.closed?
76
+ end
77
+ end
78
+ private_class_method :generate_id
79
+ private_class_method :find_handle
80
+
81
+ class VirtualInputHandle
82
+ include InputHandle
83
+
84
+ attr_reader :device_info
85
+
86
+ def initialize(device_info, on_close: nil)
87
+ @device_info = device_info
88
+ @on_close = on_close
89
+ @queue = Thread::Queue.new
90
+ @callbacks = []
91
+ @mutex = Mutex.new
92
+ @closed = false
93
+ end
94
+
95
+ def read(timeout: nil)
96
+ return nil if @closed
97
+
98
+ if timeout
99
+ @queue.pop(timeout: timeout)
100
+ else
101
+ begin
102
+ @queue.pop(true)
103
+ rescue
104
+ nil
105
+ end
106
+ end
107
+ end
108
+
109
+ def on_data(&block)
110
+ @mutex.synchronize { @callbacks << block }
111
+ end
112
+
113
+ def receive(bytes)
114
+ return if @closed
115
+
116
+ @queue.push(bytes)
117
+ @mutex.synchronize { @callbacks.dup }.each { |cb| cb.call(bytes) }
118
+ end
119
+
120
+ def close
121
+ return if @closed
122
+
123
+ @closed = true
124
+ @queue.close
125
+ @on_close&.call
126
+ end
127
+
128
+ def closed?
129
+ @closed
130
+ end
131
+ end
132
+
133
+ class VirtualOutputHandle
134
+ include OutputHandle
135
+
136
+ attr_reader :device_info
137
+
138
+ def initialize(device_info, on_close: nil)
139
+ @device_info = device_info
140
+ @on_close = on_close
141
+ @connected_inputs = []
142
+ @mutex = Mutex.new
143
+ @closed = false
144
+ @sent_messages = []
145
+ end
146
+
147
+ def write(bytes)
148
+ raise PortClosedError, "Port is closed" if @closed
149
+
150
+ connected_inputs = @mutex.synchronize do
151
+ @sent_messages << bytes
152
+ @connected_inputs.dup
153
+ end
154
+ connected_inputs.each { |input| input.receive(bytes) }
155
+ end
156
+
157
+ def connect(input_handle)
158
+ @mutex.synchronize do
159
+ @connected_inputs << input_handle unless @connected_inputs.include?(input_handle)
160
+ end
161
+ end
162
+
163
+ def disconnect(input_handle)
164
+ @mutex.synchronize { @connected_inputs.delete(input_handle) }
165
+ end
166
+
167
+ def sent_messages
168
+ @mutex.synchronize { @sent_messages.dup }
169
+ end
170
+
171
+ def close
172
+ return if @closed
173
+
174
+ @closed = true
175
+ @on_close&.call
176
+ end
177
+
178
+ def closed?
179
+ @closed
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "transport/device_info"
4
+ require_relative "transport/base"
5
+ require_relative "transport/adapter"
6
+ require_relative "transport/virtual"
7
+ require_relative "transport/null"
8
+
9
+ module Webmidi
10
+ module Transport
11
+ @registered_transports = []
12
+ @registry_mutex = Mutex.new
13
+
14
+ module_function
15
+
16
+ def register(transport)
17
+ Adapter.validate!(transport)
18
+ @registry_mutex.synchronize do
19
+ @registered_transports << transport unless @registered_transports.include?(transport)
20
+ end
21
+ transport
22
+ end
23
+
24
+ def unregister(transport)
25
+ @registry_mutex.synchronize { @registered_transports.delete(transport) }
26
+ transport
27
+ end
28
+
29
+ def registered
30
+ @registry_mutex.synchronize { @registered_transports.dup.freeze }
31
+ end
32
+
33
+ def load_adapter(name, require_path: Adapter.require_path(name), constant: Adapter.constant_name(name))
34
+ require require_path if require_path
35
+ register(Adapter.constantize(constant))
36
+ rescue LoadError => e
37
+ raise TransportNotAvailableError, "Could not load #{Adapter.gem_name(name)}: #{e.message}"
38
+ rescue NameError => e
39
+ raise TransportNotAvailableError, "Could not find transport adapter #{constant}: #{e.message}"
40
+ end
41
+
42
+ def auto_detect(transport: Webmidi.configuration.transport,
43
+ fallback_transport: Webmidi.configuration.fallback_transport,
44
+ candidates: default_candidates)
45
+ return resolve_transport!(transport) unless transport == :auto
46
+
47
+ detected = candidates.find { |candidate| candidate.respond_to?(:available?) && candidate.available? }
48
+ detected || resolve_transport!(fallback_transport)
49
+ end
50
+
51
+ def resolve_transport!(transport)
52
+ case transport
53
+ when :auto
54
+ auto_detect(transport: :auto, fallback_transport: :null)
55
+ when :virtual
56
+ available_transport!(Virtual)
57
+ when :null, nil
58
+ Null
59
+ else
60
+ unless transport.respond_to?(:available?)
61
+ raise TransportNotAvailableError, "Unknown transport: #{transport.inspect}"
62
+ end
63
+
64
+ available_transport!(transport)
65
+ end
66
+ end
67
+
68
+ def available_transport!(transport)
69
+ return transport unless transport.respond_to?(:available?) && !transport.available?
70
+
71
+ raise TransportNotAvailableError, "Transport is not available: #{transport}"
72
+ end
73
+
74
+ def default_candidates
75
+ registered + [Virtual]
76
+ end
77
+
78
+ private_class_method :resolve_transport!, :available_transport!, :default_candidates
79
+ end
80
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webmidi
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webmidi
4
+ module Virtual
5
+ class Loopback
6
+ attr_reader :input, :output
7
+
8
+ def self.create(name: "Loopback")
9
+ new(name: name)
10
+ end
11
+
12
+ def initialize(name:)
13
+ @name = name
14
+ input_handle, output_handle = Transport::Virtual.create_loopback(name)
15
+
16
+ @input = Webmidi::Port::Input.new(
17
+ id: input_handle.device_info.id,
18
+ name: input_handle.device_info.name,
19
+ manufacturer: input_handle.device_info.manufacturer,
20
+ version: input_handle.device_info.version,
21
+ transport_handle: input_handle
22
+ )
23
+ @input.open
24
+
25
+ @output = Webmidi::Port::Output.new(
26
+ id: output_handle.device_info.id,
27
+ name: output_handle.device_info.name,
28
+ manufacturer: output_handle.device_info.manufacturer,
29
+ version: output_handle.device_info.version,
30
+ transport_handle: output_handle
31
+ )
32
+
33
+ # Wire up: when data comes in from transport, dispatch to input port
34
+ input_handle.on_data do |bytes|
35
+ @input.dispatch(bytes)
36
+ end
37
+ end
38
+
39
+ def close
40
+ @input&.disconnect
41
+ @output&.disconnect
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webmidi
4
+ module Virtual
5
+ class Port
6
+ attr_reader :input, :output
7
+
8
+ def self.create(name:, direction: :bidirectional)
9
+ new(name: name, direction: direction)
10
+ end
11
+
12
+ def initialize(name:, direction: :bidirectional)
13
+ @name = name
14
+ @direction = direction
15
+ transport = Transport::Virtual
16
+
17
+ case direction
18
+ when :bidirectional, :input
19
+ input_handle = transport.create_virtual_input(name)
20
+ @input = Webmidi::Port::Input.new(
21
+ id: input_handle.device_info.id,
22
+ name: input_handle.device_info.name,
23
+ manufacturer: input_handle.device_info.manufacturer,
24
+ version: input_handle.device_info.version,
25
+ transport_handle: input_handle
26
+ )
27
+ end
28
+
29
+ case direction
30
+ when :bidirectional, :output
31
+ output_handle = transport.create_virtual_output(name)
32
+ @output = Webmidi::Port::Output.new(
33
+ id: output_handle.device_info.id,
34
+ name: output_handle.device_info.name,
35
+ manufacturer: output_handle.device_info.manufacturer,
36
+ version: output_handle.device_info.version,
37
+ transport_handle: output_handle
38
+ )
39
+ end
40
+ end
41
+
42
+ def close
43
+ @input&.disconnect
44
+ @output&.disconnect
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "virtual/port"
4
+ require_relative "virtual/loopback"
5
+
6
+ module Webmidi
7
+ module Virtual
8
+ end
9
+ end