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,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,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webmidi
4
+ module Middleware
5
+ class Base
6
+ def initialize(app, **options)
7
+ @app = app
8
+ @options = options
9
+ end
10
+
11
+ def call(message)
12
+ @app.call(message)
13
+ end
14
+ end
15
+ end
16
+ 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