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,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "message/base"
|
|
4
|
+
require_relative "message/channel"
|
|
5
|
+
require_relative "message/system"
|
|
6
|
+
require_relative "message/parser"
|
|
7
|
+
require_relative "message/ump"
|
|
8
|
+
require_relative "music/note"
|
|
9
|
+
|
|
10
|
+
module Webmidi
|
|
11
|
+
module Message
|
|
12
|
+
DEFAULT_ARGUMENT = Object.new.freeze
|
|
13
|
+
|
|
14
|
+
# Factory methods
|
|
15
|
+
def self.note_on(note, velocity: DEFAULT_ARGUMENT, channel: DEFAULT_ARGUMENT, timestamp: nil)
|
|
16
|
+
Channel::NoteOn.new(
|
|
17
|
+
note: coerce_note(note),
|
|
18
|
+
velocity: default_value(velocity, Webmidi.configuration.default_velocity),
|
|
19
|
+
channel: default_value(channel, Webmidi.configuration.default_channel),
|
|
20
|
+
timestamp: timestamp
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.note_off(note, velocity: 0, channel: DEFAULT_ARGUMENT, timestamp: nil)
|
|
25
|
+
Channel::NoteOff.new(
|
|
26
|
+
note: coerce_note(note),
|
|
27
|
+
velocity: velocity,
|
|
28
|
+
channel: default_value(channel, Webmidi.configuration.default_channel),
|
|
29
|
+
timestamp: timestamp
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.control_change(cc, value, channel: DEFAULT_ARGUMENT, timestamp: nil)
|
|
34
|
+
Channel::ControlChange.new(
|
|
35
|
+
cc: cc,
|
|
36
|
+
value: value,
|
|
37
|
+
channel: default_value(channel, Webmidi.configuration.default_channel),
|
|
38
|
+
timestamp: timestamp
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.program_change(program, channel: DEFAULT_ARGUMENT, timestamp: nil)
|
|
43
|
+
Channel::ProgramChange.new(
|
|
44
|
+
program: program,
|
|
45
|
+
channel: default_value(channel, Webmidi.configuration.default_channel),
|
|
46
|
+
timestamp: timestamp
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.channel_pressure(pressure, channel: DEFAULT_ARGUMENT, timestamp: nil)
|
|
51
|
+
Channel::ChannelPressure.new(
|
|
52
|
+
pressure: pressure,
|
|
53
|
+
channel: default_value(channel, Webmidi.configuration.default_channel),
|
|
54
|
+
timestamp: timestamp
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.polyphonic_pressure(note, pressure, channel: DEFAULT_ARGUMENT, timestamp: nil)
|
|
59
|
+
Channel::PolyphonicPressure.new(
|
|
60
|
+
note: coerce_note(note),
|
|
61
|
+
pressure: pressure,
|
|
62
|
+
channel: default_value(channel, Webmidi.configuration.default_channel),
|
|
63
|
+
timestamp: timestamp
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.pitch_bend(value = Channel::PitchBend::CENTER, channel: DEFAULT_ARGUMENT, timestamp: nil)
|
|
68
|
+
Channel::PitchBend.new(
|
|
69
|
+
value: value,
|
|
70
|
+
channel: default_value(channel, Webmidi.configuration.default_channel),
|
|
71
|
+
timestamp: timestamp
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.pitch_bend_signed(value, channel: DEFAULT_ARGUMENT, timestamp: nil)
|
|
76
|
+
Channel::PitchBend.from_signed(
|
|
77
|
+
value,
|
|
78
|
+
channel: default_value(channel, Webmidi.configuration.default_channel),
|
|
79
|
+
timestamp: timestamp
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.sysex(*data, timestamp: nil)
|
|
84
|
+
System::SysEx.new(data: data, timestamp: timestamp)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.clock(timestamp: nil)
|
|
88
|
+
System::Clock.new(timestamp: timestamp)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.start(timestamp: nil)
|
|
92
|
+
System::Start.new(timestamp: timestamp)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.continue(timestamp: nil)
|
|
96
|
+
System::Continue.new(timestamp: timestamp)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.stop(timestamp: nil)
|
|
100
|
+
System::Stop.new(timestamp: timestamp)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.active_sensing(timestamp: nil)
|
|
104
|
+
System::ActiveSensing.new(timestamp: timestamp)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.system_reset(timestamp: nil)
|
|
108
|
+
System::SystemReset.new(timestamp: timestamp)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.time_code(type, value, timestamp: nil)
|
|
112
|
+
System::TimeCode.new(type: type, value: value, timestamp: timestamp)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def self.song_position(position, timestamp: nil)
|
|
116
|
+
System::SongPosition.new(position: position, timestamp: timestamp)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.song_select(song, timestamp: nil)
|
|
120
|
+
System::SongSelect.new(song: song, timestamp: timestamp)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.tune_request(timestamp: nil)
|
|
124
|
+
System::TuneRequest.new(timestamp: timestamp)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def self.from_bytes(*bytes, normalize_note_on_zero: true)
|
|
128
|
+
bytes = bytes.flatten
|
|
129
|
+
Parser.parse_single(bytes, normalize_note_on_zero: normalize_note_on_zero)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def self.parse_many(bytes, normalize_note_on_zero: true)
|
|
133
|
+
Parser.parse_many(bytes, normalize_note_on_zero: normalize_note_on_zero)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def self.upgrade(midi1_message, group: DEFAULT_ARGUMENT)
|
|
137
|
+
UMP.upgrade(midi1_message, group: default_value(group, Webmidi.configuration.default_group))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def self.downgrade(midi2_message)
|
|
141
|
+
UMP.downgrade(midi2_message)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def self.coerce_note(note)
|
|
145
|
+
Music::Note.to_midi(note)
|
|
146
|
+
end
|
|
147
|
+
private_class_method :coerce_note
|
|
148
|
+
|
|
149
|
+
def self.default_value(value, default)
|
|
150
|
+
value.equal?(DEFAULT_ARGUMENT) ? default : value
|
|
151
|
+
end
|
|
152
|
+
private_class_method :default_value
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module Middleware
|
|
5
|
+
class ChannelMap < Base
|
|
6
|
+
def initialize(app, map: nil, from: nil, to: nil, **options)
|
|
7
|
+
super(app, **options)
|
|
8
|
+
@map = normalize_map(map, from, to)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(message)
|
|
12
|
+
return @app.call(message) unless message.channel
|
|
13
|
+
|
|
14
|
+
target = @map.fetch(message.channel, message.channel)
|
|
15
|
+
@app.call(message.with(channel: target))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def normalize_map(map, from, to)
|
|
21
|
+
mapping = map || ((from.nil? || to.nil?) ? {} : {from => to})
|
|
22
|
+
mapping.each_with_object({}) do |(source, target), result|
|
|
23
|
+
validate_channel!(source, "source channel")
|
|
24
|
+
validate_channel!(target, "target channel")
|
|
25
|
+
result[source] = target
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def validate_channel!(channel, name)
|
|
30
|
+
return if channel.is_a?(Integer) && channel.between?(0, 15)
|
|
31
|
+
|
|
32
|
+
raise InvalidMessageError, "#{name} must be between 0 and 15, got #{channel.inspect}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module Middleware
|
|
5
|
+
class Filter < Base
|
|
6
|
+
def initialize(app, channels: nil, types: nil, include_system: true, **options)
|
|
7
|
+
super(app, **options)
|
|
8
|
+
@channels = channels
|
|
9
|
+
@types = types
|
|
10
|
+
@include_system = include_system
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(message)
|
|
14
|
+
return nil if @channels && !message.channel && !@include_system
|
|
15
|
+
return nil if @channels && message.channel && !@channels.include?(message.channel)
|
|
16
|
+
return nil if @types && !@types.any? { |t| message.is_a?(t) }
|
|
17
|
+
|
|
18
|
+
@app.call(message)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module Middleware
|
|
5
|
+
class Logger < Base
|
|
6
|
+
def initialize(app, output: $stderr, **options)
|
|
7
|
+
super(app, **options)
|
|
8
|
+
@output = output
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(message)
|
|
12
|
+
@output.puts "[MIDI] #{message.class.name.split("::").last}: #{message.to_hex}"
|
|
13
|
+
@app.call(message)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module Middleware
|
|
5
|
+
class NoteRangeFilter < Base
|
|
6
|
+
def initialize(app, min: 0, max: 127, **options)
|
|
7
|
+
super(app, **options)
|
|
8
|
+
validate_note!(min, "min")
|
|
9
|
+
validate_note!(max, "max")
|
|
10
|
+
raise InvalidMessageError, "min cannot be greater than max" if min > max
|
|
11
|
+
|
|
12
|
+
@range = min..max
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(message)
|
|
16
|
+
return nil if note_message?(message) && !@range.cover?(message.note)
|
|
17
|
+
|
|
18
|
+
@app.call(message)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def note_message?(message)
|
|
24
|
+
message.respond_to?(:note)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def validate_note!(note, name)
|
|
28
|
+
return if note.is_a?(Integer) && note.between?(0, 127)
|
|
29
|
+
|
|
30
|
+
raise InvalidMessageError, "#{name} must be between 0 and 127, got #{note.inspect}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module Middleware
|
|
5
|
+
class Panic < Base
|
|
6
|
+
DEFAULT_CONTROLS = [:all_sound_off, :all_notes_off].freeze
|
|
7
|
+
DEFAULT_TRIGGER = Message::System::SystemReset
|
|
8
|
+
|
|
9
|
+
def initialize(app, channels: 0..15, controls: DEFAULT_CONTROLS, trigger: DEFAULT_TRIGGER,
|
|
10
|
+
pass_trigger: false, **options)
|
|
11
|
+
super(app, **options)
|
|
12
|
+
@channels = self.class.send(:normalize_channels, channels)
|
|
13
|
+
@controls = self.class.send(:normalize_controls, controls)
|
|
14
|
+
@trigger = trigger
|
|
15
|
+
@pass_trigger = pass_trigger
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(message)
|
|
19
|
+
return @app.call(message) unless trigger?(message)
|
|
20
|
+
|
|
21
|
+
results = self.class.messages(channels: @channels, controls: @controls, timestamp: message.timestamp)
|
|
22
|
+
.filter_map { |panic_message| @app.call(panic_message) }
|
|
23
|
+
results << @app.call(message) if @pass_trigger
|
|
24
|
+
results.compact
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.all_notes_off(channels: 0..15, timestamp: nil)
|
|
28
|
+
messages(channels: channels, controls: [:all_notes_off], timestamp: timestamp)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.messages(channels: 0..15, controls: DEFAULT_CONTROLS, timestamp: nil)
|
|
32
|
+
normalize_channels(channels).flat_map do |channel|
|
|
33
|
+
normalize_controls(controls).map do |control|
|
|
34
|
+
Message.control_change(control, 0, channel: channel, timestamp: timestamp)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.normalize_channels(channels)
|
|
40
|
+
Array(channels).each_with_object([]) do |channel, result|
|
|
41
|
+
unless channel.is_a?(Integer) && channel.between?(0, 15)
|
|
42
|
+
raise InvalidMessageError, "Channel must be between 0 and 15, got #{channel.inspect}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
result << channel
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.normalize_controls(controls)
|
|
50
|
+
Array(controls).map do |control|
|
|
51
|
+
Message::Channel::ControlChange.controller_number(control)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private_class_method :normalize_channels, :normalize_controls
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def trigger?(message)
|
|
60
|
+
case @trigger
|
|
61
|
+
when nil
|
|
62
|
+
false
|
|
63
|
+
when Proc
|
|
64
|
+
@trigger.call(message)
|
|
65
|
+
when Class, Module
|
|
66
|
+
message.is_a?(@trigger)
|
|
67
|
+
else
|
|
68
|
+
message == @trigger
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module Middleware
|
|
5
|
+
class Pipeline
|
|
6
|
+
def initialize(input, stack = nil)
|
|
7
|
+
@input = input
|
|
8
|
+
@stack = stack || Stack.new
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def to(output)
|
|
12
|
+
@input.on_message do |message|
|
|
13
|
+
processed = @stack.call(message)
|
|
14
|
+
output.send(processed) if processed
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module Middleware
|
|
5
|
+
class Recorder < Base
|
|
6
|
+
attr_reader :tape
|
|
7
|
+
|
|
8
|
+
def initialize(app = nil, **options)
|
|
9
|
+
super(app || ->(msg) { msg }, **options)
|
|
10
|
+
@tape = Tape.new
|
|
11
|
+
@recording = false
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(message)
|
|
16
|
+
tape = @mutex.synchronize { @recording ? @tape : nil }
|
|
17
|
+
tape&.add(message)
|
|
18
|
+
@app.call(message)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def record
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
@tape = Tape.new
|
|
24
|
+
@recording = true
|
|
25
|
+
end
|
|
26
|
+
if block_given?
|
|
27
|
+
begin
|
|
28
|
+
yield
|
|
29
|
+
ensure
|
|
30
|
+
@mutex.synchronize { @recording = false }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
@mutex.synchronize { @tape }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def stop
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
@recording = false
|
|
39
|
+
@tape
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def recording?
|
|
44
|
+
@mutex.synchronize { @recording }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class Tape
|
|
48
|
+
def initialize(entries: [], start_time: nil)
|
|
49
|
+
@messages = entries.map(&:dup)
|
|
50
|
+
@start_time = start_time
|
|
51
|
+
@mutex = Mutex.new
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def add(message)
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
@start_time ||= message.timestamp
|
|
57
|
+
@messages << {message: message, time: message.timestamp - @start_time}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def messages
|
|
62
|
+
snapshot.lazy.map { |entry| entry[:message] }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def message_count
|
|
66
|
+
@mutex.synchronize { @messages.size }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def duration
|
|
70
|
+
entries = snapshot
|
|
71
|
+
return 0.0 if entries.empty?
|
|
72
|
+
|
|
73
|
+
entries.last[:time]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def play(output, speed: 1.0)
|
|
77
|
+
play_from(0.0, output, speed: speed)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def play_from(time, output, speed: 1.0)
|
|
81
|
+
validate_speed!(speed)
|
|
82
|
+
entries = snapshot.select { |e| e[:time] >= time }
|
|
83
|
+
last_time = time
|
|
84
|
+
|
|
85
|
+
entries.each do |entry|
|
|
86
|
+
delay = (entry[:time] - last_time) / speed
|
|
87
|
+
sleep(delay) if delay > 0.001
|
|
88
|
+
output.send(entry[:message])
|
|
89
|
+
last_time = entry[:time]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def rewind(seconds)
|
|
94
|
+
target = duration - seconds
|
|
95
|
+
target = 0.0 if target < 0
|
|
96
|
+
snapshot.select { |e| e[:time] >= target }.map { |e| e[:message] }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def slice(from, to)
|
|
100
|
+
entries = snapshot.select { |e| e[:time].between?(from, to) }.map do |entry|
|
|
101
|
+
{
|
|
102
|
+
message: entry[:message],
|
|
103
|
+
time: entry[:time] - from
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
Tape.new(entries: entries, start_time: 0.0)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def snapshot
|
|
112
|
+
@mutex.synchronize { @messages.map(&:dup) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def validate_speed!(speed)
|
|
116
|
+
return if speed.is_a?(Numeric) && speed.positive?
|
|
117
|
+
|
|
118
|
+
raise InvalidMessageError, "Playback speed must be positive, got #{speed.inspect}"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module Middleware
|
|
5
|
+
class SplitByChannel < Base
|
|
6
|
+
SYSTEM_ROUTE = :system
|
|
7
|
+
|
|
8
|
+
def initialize(app, routes:, passthrough: false, **options)
|
|
9
|
+
super(app, **options)
|
|
10
|
+
@routes = normalize_routes(routes)
|
|
11
|
+
@passthrough = passthrough
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(message)
|
|
15
|
+
targets = @routes[route_key(message)]
|
|
16
|
+
return @app.call(message) unless targets
|
|
17
|
+
|
|
18
|
+
targets.each { |target| deliver(target, message) }
|
|
19
|
+
return @app.call(message) if @passthrough
|
|
20
|
+
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def route_key(message)
|
|
27
|
+
message.channel || SYSTEM_ROUTE
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def normalize_routes(routes)
|
|
31
|
+
unless routes.respond_to?(:each)
|
|
32
|
+
raise InvalidMessageError, "routes must be enumerable, got #{routes.class}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
routes.each_with_object({}) do |(channel, targets), result|
|
|
36
|
+
key = normalize_route_key(channel)
|
|
37
|
+
result[key] = Array(targets).tap { |list| list.each { |target| validate_target!(target) } }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def normalize_route_key(channel)
|
|
42
|
+
return SYSTEM_ROUTE if channel == SYSTEM_ROUTE
|
|
43
|
+
|
|
44
|
+
unless channel.is_a?(Integer) && channel.between?(0, 15)
|
|
45
|
+
raise InvalidMessageError, "Route channel must be between 0 and 15, got #{channel.inspect}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
channel
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate_target!(target)
|
|
52
|
+
return if target.respond_to?(:call) || target.respond_to?(:<<)
|
|
53
|
+
|
|
54
|
+
raise InvalidMessageError, "Route target must respond to call or <<, got #{target.class}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def deliver(target, message)
|
|
58
|
+
if target.respond_to?(:call)
|
|
59
|
+
target.call(message)
|
|
60
|
+
else
|
|
61
|
+
target << message
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module Middleware
|
|
5
|
+
class Stack
|
|
6
|
+
def initialize(&block)
|
|
7
|
+
@middlewares = []
|
|
8
|
+
@app_cache = nil
|
|
9
|
+
instance_eval(&block) if block
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def use(middleware_class_or_proc, **options)
|
|
13
|
+
@middlewares << [middleware_class_or_proc, options]
|
|
14
|
+
@app_cache = nil
|
|
15
|
+
self
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(message)
|
|
19
|
+
build.call(message)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def build
|
|
23
|
+
return @app_cache if @app_cache
|
|
24
|
+
|
|
25
|
+
endpoint = ->(msg) { msg }
|
|
26
|
+
@middlewares.reverse_each do |middleware, options|
|
|
27
|
+
current_app = endpoint
|
|
28
|
+
endpoint = if middleware.is_a?(Proc)
|
|
29
|
+
lambda_adapter(middleware, current_app)
|
|
30
|
+
else
|
|
31
|
+
middleware.new(current_app, **options)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
@app_cache = endpoint
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def lambda_adapter(proc, app)
|
|
40
|
+
LambdaMiddleware.new(app, proc)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class LambdaMiddleware
|
|
44
|
+
def initialize(app, proc)
|
|
45
|
+
@app = app
|
|
46
|
+
@proc = proc
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def call(message)
|
|
50
|
+
@proc.call(message, @app)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module Middleware
|
|
5
|
+
class Debounce < Base
|
|
6
|
+
def initialize(app, interval:, key: nil, **options)
|
|
7
|
+
super(app, **options)
|
|
8
|
+
validate_interval!(interval)
|
|
9
|
+
@interval = interval
|
|
10
|
+
@key = key || ->(message) { message.to_bytes }
|
|
11
|
+
@last_seen = {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(message)
|
|
15
|
+
key = @key.call(message)
|
|
16
|
+
now = message.timestamp
|
|
17
|
+
last = @last_seen[key]
|
|
18
|
+
return nil if last && (now - last) < @interval
|
|
19
|
+
|
|
20
|
+
@last_seen[key] = now
|
|
21
|
+
@app.call(message)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def validate_interval!(interval)
|
|
27
|
+
return if interval.is_a?(Numeric) && interval.positive?
|
|
28
|
+
|
|
29
|
+
raise InvalidMessageError, "interval must be positive, got #{interval.inspect}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class Throttle < Base
|
|
34
|
+
def initialize(app, interval:, **options)
|
|
35
|
+
super(app, **options)
|
|
36
|
+
validate_interval!(interval)
|
|
37
|
+
@interval = interval
|
|
38
|
+
@last_sent = nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call(message)
|
|
42
|
+
now = message.timestamp
|
|
43
|
+
return nil if @last_sent && (now - @last_sent) < @interval
|
|
44
|
+
|
|
45
|
+
@last_sent = now
|
|
46
|
+
@app.call(message)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def validate_interval!(interval)
|
|
52
|
+
return if interval.is_a?(Numeric) && interval.positive?
|
|
53
|
+
|
|
54
|
+
raise InvalidMessageError, "interval must be positive, got #{interval.inspect}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|