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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fc76ddd4eadafc9aeedd43adf4f2a7df4389f3145e8ab92ad826a07def6a2fdb
4
+ data.tar.gz: 3c55c45cba163273c17e5691a11fd7fe8d76bbacf0a8c4adda66d73bf7618431
5
+ SHA512:
6
+ metadata.gz: 6edb7b915fbc4a97446aa418a65bd914ae23502688e7d8bab47a79696daf7ad373d28a9f1c941424853024ad82223bd35e72589d90116fa3a6bf82a88f67e514
7
+ data.tar.gz: c589a81b550df457c2c98d6e4ba9fd22d7d8a14fd64d5cc65b208656f95db24b0b6128f599acc60acefc1fe9333f501fb4c045ce234cec3a069e25fe61c55887
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-05-25
4
+
5
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # Webmidi
2
+
3
+ Webmidi is a pure-Ruby MIDI library inspired by the W3C Web MIDI API.
4
+ It provides MIDI messages, ports, Standard MIDI File I/O, middleware,
5
+ network MIDI, and MIDI 2.0 UMP support with zero runtime dependencies.
6
+
7
+ ## Requirements
8
+
9
+ - Ruby 3.2 or newer
10
+
11
+ ## Installation
12
+
13
+ ```ruby
14
+ gem "webmidi"
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ```ruby
20
+ require "webmidi"
21
+
22
+ loopback = Webmidi::Virtual::Loopback.create(name: "Demo")
23
+
24
+ loopback.input.on_message do |message|
25
+ puts message.to_hex
26
+ end
27
+
28
+ loopback.output.note_on(60, velocity: 100)
29
+ loopback.output.note_off(60)
30
+
31
+ loopback.close
32
+ ```
33
+
34
+ ## Core APIs
35
+
36
+ ```ruby
37
+ # MIDI messages
38
+ message = Webmidi::Message.note_on(:C4, velocity: 100, channel: 0)
39
+ message.to_bytes # => [0x90, 60, 100]
40
+
41
+ parsed = Webmidi::Message.from_bytes(0x90, 60, 100)
42
+
43
+ # Standard MIDI Files
44
+ sequence = Webmidi::SMF::Sequence.read("input.mid")
45
+ sequence.write("output.mid")
46
+
47
+ # MIDI 2.0 UMP
48
+ ump = Webmidi::Message.upgrade(message)
49
+ midi1 = Webmidi::Message.downgrade(ump)
50
+ ```
51
+
52
+ ## Included
53
+
54
+ - MIDI 1.0 channel and system messages
55
+ - MIDI byte parsing, stream parsing, and running status support
56
+ - Virtual input/output ports and loopback ports
57
+ - Standard MIDI File format 0/1 reader and writer
58
+ - Middleware for filtering, transposition, velocity changes, logging, recording, and routing
59
+ - Music helpers for notes, chords, scales, rhythm, and frequencies
60
+ - RTP-MIDI, AppleMIDI negotiation, and OSC bridging
61
+ - MIDI 2.0 Universal MIDI Packet parsing and MIDI 1.0 conversion
62
+
63
+ ## Development
64
+
65
+ ```bash
66
+ bundle install
67
+ bundle exec rake spec
68
+ bundle exec rake release:check
69
+ ```
70
+
71
+ ## License
72
+
73
+ MIT License. See [LICENSE.txt](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "tmpdir"
6
+
7
+ RSpec::Core::RakeTask.new(:spec)
8
+
9
+ task :coverage do
10
+ ENV["COVERAGE"] = "1"
11
+ Rake::Task[:spec].invoke
12
+ end
13
+
14
+ task :mutation do
15
+ unless Gem::Specification.find_all_by_name("mutant-rspec").any?
16
+ warn "mutant-rspec is not installed; skipping mutation run"
17
+ next
18
+ end
19
+
20
+ sh "bundle exec mutant run --use rspec Webmidi*"
21
+ end
22
+
23
+ task :standard do
24
+ ENV["RUBOCOP_CACHE_ROOT"] ||= File.expand_path("tmp/rubocop_cache", __dir__)
25
+ sh "standardrb"
26
+ end
27
+
28
+ task :smoke_require do
29
+ ruby "-Ilib", "-e", "require 'webmidi'"
30
+ end
31
+
32
+ task :build_gem do
33
+ Dir.mktmpdir("webmidi-gem-build") do |dir|
34
+ sh "gem build webmidi.gemspec --output #{File.join(dir, "webmidi.gem")}"
35
+ end
36
+ end
37
+
38
+ namespace :release do
39
+ task check: [:standard, :spec, :smoke_require, :build_gem] do
40
+ spec = Gem::Specification.load("webmidi.gemspec")
41
+ raise "Missing version" if spec.version.to_s.empty?
42
+ raise "CHANGELOG.md is missing" unless File.exist?("CHANGELOG.md")
43
+ raise "MFA metadata is required" unless spec.metadata["rubygems_mfa_required"] == "true"
44
+ raise "gemspec must be included in package files" unless spec.files.include?("webmidi.gemspec")
45
+ end
46
+ end
47
+
48
+ task default: :spec
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "callback_subscription"
4
+
5
+ module Webmidi
6
+ class Access
7
+ include Enumerable
8
+
9
+ attr_reader :sysex_enabled
10
+
11
+ alias_method :sysex_enabled?, :sysex_enabled
12
+
13
+ def initialize(sysex: false, transport: nil)
14
+ @sysex_enabled = sysex
15
+ @transport = transport ? Transport.send(:resolve_transport!, transport) : Transport.auto_detect
16
+ @inputs = Port::Map.new
17
+ @outputs = Port::Map.new
18
+ @state_change_callbacks = []
19
+ @port_state_subscriptions = []
20
+ @mutex = Mutex.new
21
+ refresh_ports
22
+ end
23
+
24
+ def inputs
25
+ @mutex.synchronize { @inputs.snapshot }
26
+ end
27
+
28
+ def outputs
29
+ @mutex.synchronize { @outputs.snapshot }
30
+ end
31
+
32
+ def input(name_or_id)
33
+ @mutex.synchronize { @inputs[name_or_id] }
34
+ end
35
+
36
+ def output(name_or_id)
37
+ @mutex.synchronize { @outputs[name_or_id] }
38
+ end
39
+
40
+ def fetch_input!(name_or_id)
41
+ input(name_or_id) || raise(PortNotFoundError, "Input port not found: #{name_or_id}")
42
+ end
43
+
44
+ def fetch_output!(name_or_id)
45
+ output(name_or_id) || raise(PortNotFoundError, "Output port not found: #{name_or_id}")
46
+ end
47
+
48
+ def on_state_change(&block)
49
+ raise ArgumentError, "on_state_change requires a block" unless block
50
+
51
+ @mutex.synchronize { @state_change_callbacks << block }
52
+ CallbackSubscription.new do
53
+ @mutex.synchronize { @state_change_callbacks.delete(block) }
54
+ end
55
+ end
56
+
57
+ def close
58
+ each(&:disconnect)
59
+ self
60
+ end
61
+
62
+ def each(&block)
63
+ ports = @mutex.synchronize { @inputs.to_a + @outputs.to_a }
64
+ ports.each(&block)
65
+ end
66
+
67
+ def create_input(name)
68
+ handle = @transport.create_virtual_input(name)
69
+ port = Port::Input.new(
70
+ id: handle.device_info.id,
71
+ name: handle.device_info.name,
72
+ manufacturer: handle.device_info.manufacturer,
73
+ version: handle.device_info.version,
74
+ transport_handle: handle,
75
+ sysex_enabled: @sysex_enabled
76
+ )
77
+ register_port(port, @inputs)
78
+ port
79
+ end
80
+
81
+ def create_output(name)
82
+ handle = @transport.create_virtual_output(name)
83
+ port = Port::Output.new(
84
+ id: handle.device_info.id,
85
+ name: handle.device_info.name,
86
+ manufacturer: handle.device_info.manufacturer,
87
+ version: handle.device_info.version,
88
+ transport_handle: handle,
89
+ sysex_enabled: @sysex_enabled
90
+ )
91
+ register_port(port, @outputs)
92
+ port
93
+ end
94
+
95
+ def refresh_ports
96
+ sync_ports(@inputs, @transport.list_inputs, Port::Input, :open_input)
97
+ sync_ports(@outputs, @transport.list_outputs, Port::Output, :open_output)
98
+ self
99
+ end
100
+
101
+ private
102
+
103
+ def sync_ports(map, infos, port_class, open_method)
104
+ current_ids = infos.map(&:id)
105
+ removed = []
106
+ added = []
107
+
108
+ @mutex.synchronize do
109
+ map.to_a.reject { |port| current_ids.include?(port.id) }.each do |port|
110
+ map.remove(port)
111
+ removed << port
112
+ end
113
+
114
+ infos.each do |info|
115
+ next if map[info.id]
116
+
117
+ handle = @transport.respond_to?(open_method) ? @transport.public_send(open_method, info) : nil
118
+ port = port_class.new(
119
+ id: info.id, name: info.name,
120
+ manufacturer: info.manufacturer, version: info.version,
121
+ transport_handle: handle,
122
+ sysex_enabled: @sysex_enabled
123
+ )
124
+ map.add(port)
125
+ watch_port(port)
126
+ added << port
127
+ end
128
+ end
129
+
130
+ removed.each do |port|
131
+ port.disconnect
132
+ notify_state_change(port)
133
+ end
134
+ added.each { |port| notify_state_change(port) }
135
+ end
136
+
137
+ def register_port(port, map)
138
+ @mutex.synchronize do
139
+ map.add(port)
140
+ watch_port(port)
141
+ end
142
+ notify_state_change(port)
143
+ end
144
+
145
+ def watch_port(port)
146
+ subscription = port.on_state_change { |changed_port| notify_state_change(changed_port) }
147
+ @port_state_subscriptions << subscription
148
+ end
149
+
150
+ def notify_state_change(port)
151
+ callbacks = @mutex.synchronize { @state_change_callbacks.dup }
152
+ callbacks.each { |cb| cb.call(port) }
153
+ end
154
+ end
155
+
156
+ class << self
157
+ def request_access(sysex: Webmidi.configuration.sysex, &block)
158
+ access = Access.new(sysex: sysex)
159
+ if block
160
+ begin
161
+ block.call(access)
162
+ ensure
163
+ access.close
164
+ end
165
+ else
166
+ access
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webmidi
4
+ class CallbackSubscription
5
+ def initialize(&unsubscribe)
6
+ @unsubscribe = unsubscribe
7
+ @active = true
8
+ @mutex = Mutex.new
9
+ end
10
+
11
+ def unsubscribe
12
+ callback = @mutex.synchronize do
13
+ return false unless @active
14
+
15
+ @active = false
16
+ @unsubscribe
17
+ end
18
+ callback.call
19
+ true
20
+ end
21
+
22
+ def active?
23
+ @mutex.synchronize { @active }
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "callback_subscription"
4
+
5
+ module Webmidi
6
+ class Clock
7
+ PPQN = 24
8
+
9
+ attr_reader :bpm, :running
10
+
11
+ alias_method :running?, :running
12
+
13
+ def initialize(bpm: 120)
14
+ validate_bpm!(bpm)
15
+ @bpm = bpm
16
+ @running = false
17
+ @callbacks = []
18
+ @error_callbacks = []
19
+ @mutex = Mutex.new
20
+ @thread = nil
21
+ @tick_count = 0
22
+ end
23
+
24
+ def bpm=(new_bpm)
25
+ validate_bpm!(new_bpm)
26
+ @mutex.synchronize { @bpm = new_bpm }
27
+ end
28
+
29
+ def start
30
+ @mutex.synchronize do
31
+ return self if @running
32
+
33
+ @running = true
34
+ @tick_count = 0
35
+ @thread = Thread.new { clock_loop }
36
+ end
37
+ self
38
+ end
39
+
40
+ def stop
41
+ thread = @mutex.synchronize do
42
+ @running = false
43
+ @thread
44
+ end
45
+ thread&.join(1) if thread && thread != Thread.current
46
+ @mutex.synchronize { @thread = nil if @thread == thread }
47
+ self
48
+ end
49
+
50
+ def on_tick(&block)
51
+ raise ArgumentError, "on_tick requires a block" unless block
52
+
53
+ @mutex.synchronize { @callbacks << block }
54
+ CallbackSubscription.new do
55
+ @mutex.synchronize { @callbacks.delete(block) }
56
+ end
57
+ end
58
+
59
+ def on_error(&block)
60
+ raise ArgumentError, "on_error requires a block" unless block
61
+
62
+ @mutex.synchronize { @error_callbacks << block }
63
+ CallbackSubscription.new do
64
+ @mutex.synchronize { @error_callbacks.delete(block) }
65
+ end
66
+ end
67
+
68
+ def pipe_to(output)
69
+ on_tick { output.send(Message.clock) }
70
+ end
71
+
72
+ def start_message
73
+ Message.start
74
+ end
75
+
76
+ def stop_message
77
+ Message.stop
78
+ end
79
+
80
+ def tick_count
81
+ @mutex.synchronize { @tick_count }
82
+ end
83
+
84
+ def beat_count
85
+ @mutex.synchronize { @tick_count / PPQN }
86
+ end
87
+
88
+ private
89
+
90
+ def clock_loop
91
+ next_tick = monotonic_now
92
+ loop do
93
+ running, interval = @mutex.synchronize { [@running, 60.0 / (@bpm * PPQN)] }
94
+ break unless running
95
+
96
+ next_tick += interval
97
+ sleep_time = next_tick - monotonic_now
98
+ sleep(sleep_time) if sleep_time.positive?
99
+
100
+ callbacks, tick = @mutex.synchronize do
101
+ next [nil, nil] unless @running
102
+
103
+ @tick_count += 1
104
+ [@callbacks.dup, @tick_count]
105
+ end
106
+ next unless callbacks
107
+
108
+ callbacks.each { |cb| safely_call(cb, tick) }
109
+ end
110
+ end
111
+
112
+ def safely_call(callback, tick)
113
+ callback.call(tick)
114
+ rescue => e
115
+ error_callbacks = @mutex.synchronize { @error_callbacks.dup }
116
+ error_callbacks.each { |cb| cb.call(e, tick) }
117
+ end
118
+
119
+ def monotonic_now
120
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
121
+ end
122
+
123
+ def validate_bpm!(bpm)
124
+ return if bpm.is_a?(Numeric) && bpm.positive?
125
+
126
+ raise InvalidMessageError, "BPM must be positive, got #{bpm.inspect}"
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webmidi
4
+ class Configuration
5
+ attr_accessor :transport, :fallback_transport,
6
+ :default_channel, :default_velocity,
7
+ :default_group,
8
+ :sysex,
9
+ :logger, :log_level,
10
+ :timestamp_source
11
+
12
+ def initialize
13
+ reset!
14
+ end
15
+
16
+ def reset!
17
+ @transport = :auto
18
+ @fallback_transport = :virtual
19
+ @default_channel = 0
20
+ @default_velocity = 100
21
+ @default_group = 0
22
+ @sysex = false
23
+ @logger = nil
24
+ @log_level = :info
25
+ @timestamp_source = :monotonic
26
+ self
27
+ end
28
+ end
29
+
30
+ class << self
31
+ def configuration
32
+ @configuration ||= Configuration.new
33
+ end
34
+
35
+ def configure
36
+ yield(configuration)
37
+ end
38
+
39
+ def reset_configuration!
40
+ @configuration = Configuration.new
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webmidi
4
+ class Error < StandardError; end
5
+
6
+ # Port-related errors
7
+ class PortNotFoundError < Error; end
8
+ class PortOpenError < Error; end
9
+ class PortClosedError < Error; end
10
+
11
+ # Message-related errors
12
+ class InvalidMessageError < Error; end
13
+ class SysExNotPermittedError < Error; end
14
+
15
+ # File-related errors
16
+ class InvalidSMFError < Error; end
17
+ class UnsupportedFormatError < Error; end
18
+
19
+ # Network-related errors
20
+ class NetworkError < Error; end
21
+ class ConnectionTimeoutError < NetworkError; end
22
+
23
+ # Transport-related errors
24
+ class TransportNotAvailableError < Error; end
25
+ class TransportError < Error; end
26
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Webmidi
4
+ module Message
5
+ class Base
6
+ attr_reader :timestamp
7
+
8
+ def initialize(timestamp: nil)
9
+ @timestamp = timestamp || Process.clock_gettime(Process::CLOCK_MONOTONIC)
10
+ freeze
11
+ end
12
+
13
+ def to_bytes
14
+ raise NotImplementedError, "#{self.class}#to_bytes must be implemented"
15
+ end
16
+
17
+ def to_hex
18
+ to_bytes.map { |b| format("%02X", b) }.join(" ")
19
+ end
20
+
21
+ def to_binary
22
+ to_bytes.pack("C*").b
23
+ end
24
+
25
+ def channel
26
+ nil
27
+ end
28
+
29
+ def ==(other)
30
+ other.is_a?(self.class) && same_bytes?(other)
31
+ end
32
+
33
+ def eql?(other)
34
+ self == other
35
+ end
36
+
37
+ def hash
38
+ [self.class, to_bytes].hash
39
+ end
40
+
41
+ def same_bytes?(other)
42
+ other.respond_to?(:to_bytes) && to_bytes == other.to_bytes
43
+ end
44
+
45
+ def same_event?(other)
46
+ other.is_a?(self.class) && same_bytes?(other) && timestamp == other.timestamp
47
+ end
48
+
49
+ def with(**changes)
50
+ changes = changes.dup
51
+ next_timestamp = changes.key?(:timestamp) ? changes.delete(:timestamp) : @timestamp
52
+ self.class.new(**deconstruct_keys(nil).merge(changes), timestamp: next_timestamp)
53
+ end
54
+
55
+ def deconstruct
56
+ to_bytes
57
+ end
58
+
59
+ def deconstruct_keys(keys)
60
+ {}
61
+ end
62
+ end
63
+ end
64
+ end