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,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module SMF
|
|
5
|
+
module Reader
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def read(path_or_io, **options)
|
|
9
|
+
data = if path_or_io.respond_to?(:read)
|
|
10
|
+
path_or_io.read
|
|
11
|
+
else
|
|
12
|
+
File.binread(path_or_io)
|
|
13
|
+
end
|
|
14
|
+
parse(data, **options)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def parse(binary, skip_unknown_chunks: true, stop_at_end_of_track: true)
|
|
18
|
+
binary = binary.b if binary.encoding != Encoding::ASCII_8BIT
|
|
19
|
+
stream = StringStream.new(binary)
|
|
20
|
+
|
|
21
|
+
format, num_tracks, ppqn = read_header(stream)
|
|
22
|
+
sequence = Sequence.new(format: format, ppqn: ppqn)
|
|
23
|
+
|
|
24
|
+
num_tracks.times do
|
|
25
|
+
track = read_track(stream, skip_unknown_chunks: skip_unknown_chunks,
|
|
26
|
+
stop_at_end_of_track: stop_at_end_of_track)
|
|
27
|
+
sequence.add_track(track)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
sequence
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def read_header(stream)
|
|
34
|
+
chunk_id = stream.read_bytes(4)
|
|
35
|
+
raise InvalidSMFError, "Invalid SMF header: expected 'MThd'" unless chunk_id == "MThd"
|
|
36
|
+
|
|
37
|
+
chunk_size = stream.read_uint32
|
|
38
|
+
raise InvalidSMFError, "Invalid header size: #{chunk_size}" unless chunk_size == 6
|
|
39
|
+
|
|
40
|
+
format = stream.read_uint16
|
|
41
|
+
num_tracks = stream.read_uint16
|
|
42
|
+
division = stream.read_uint16
|
|
43
|
+
raise InvalidSMFError, "SMF format 0 must have exactly one track" if format.zero? && num_tracks != 1
|
|
44
|
+
|
|
45
|
+
if (division & 0x8000).zero?
|
|
46
|
+
ppqn = division
|
|
47
|
+
else
|
|
48
|
+
raise UnsupportedFormatError, "SMPTE time division is not supported"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
[format, num_tracks, ppqn]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def read_track(stream, skip_unknown_chunks:, stop_at_end_of_track:) # rubocop:disable Metrics/MethodLength
|
|
55
|
+
loop do
|
|
56
|
+
chunk_id = stream.read_bytes(4)
|
|
57
|
+
chunk_size = stream.read_uint32
|
|
58
|
+
|
|
59
|
+
unless chunk_id == "MTrk"
|
|
60
|
+
raise InvalidSMFError, "Invalid track header: expected 'MTrk', got '#{chunk_id}'" unless skip_unknown_chunks
|
|
61
|
+
|
|
62
|
+
stream.skip(chunk_size)
|
|
63
|
+
next
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
track_end = stream.position + chunk_size
|
|
67
|
+
track = Track.new
|
|
68
|
+
running_status = nil
|
|
69
|
+
absolute_time = 0
|
|
70
|
+
|
|
71
|
+
stream.with_limit(track_end) do
|
|
72
|
+
while stream.position < track_end
|
|
73
|
+
delta_time = stream.read_vlq
|
|
74
|
+
absolute_time += delta_time
|
|
75
|
+
|
|
76
|
+
status_byte = stream.peek_byte
|
|
77
|
+
|
|
78
|
+
if status_byte >= 0x80
|
|
79
|
+
stream.read_byte
|
|
80
|
+
running_status = status_byte if status_byte < 0xF0
|
|
81
|
+
else
|
|
82
|
+
status_byte = running_status
|
|
83
|
+
raise InvalidSMFError, "No running status available" unless status_byte
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
event = parse_event(stream, status_byte, delta_time, absolute_time)
|
|
87
|
+
track << event if event
|
|
88
|
+
if stop_at_end_of_track && end_of_track?(event)
|
|
89
|
+
stream.skip(track_end - stream.position)
|
|
90
|
+
break
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
return track
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def parse_event(stream, status_byte, delta_time, absolute_time)
|
|
100
|
+
case status_byte
|
|
101
|
+
when 0xFF
|
|
102
|
+
parse_meta_event(stream, delta_time, absolute_time)
|
|
103
|
+
when 0xF0, 0xF7
|
|
104
|
+
parse_sysex_event(stream, delta_time, absolute_time)
|
|
105
|
+
else
|
|
106
|
+
parse_midi_event(stream, status_byte, delta_time, absolute_time)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def parse_meta_event(stream, delta_time, absolute_time)
|
|
111
|
+
type = stream.read_byte
|
|
112
|
+
length = stream.read_vlq
|
|
113
|
+
data = stream.read_raw_bytes(length)
|
|
114
|
+
MetaEvent.new(type: type, data: data, delta_time: delta_time, absolute_time: absolute_time)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def parse_sysex_event(stream, delta_time, absolute_time)
|
|
118
|
+
length = stream.read_vlq
|
|
119
|
+
data = stream.read_raw_bytes(length)
|
|
120
|
+
SysExEvent.new(data: data, delta_time: delta_time, absolute_time: absolute_time)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def parse_midi_event(stream, status_byte, delta_time, absolute_time) # rubocop:disable Metrics/MethodLength
|
|
124
|
+
high = status_byte & 0xF0
|
|
125
|
+
|
|
126
|
+
bytes = case high
|
|
127
|
+
when 0xC0, 0xD0
|
|
128
|
+
[status_byte, stream.read_byte]
|
|
129
|
+
when 0x80, 0x90, 0xA0, 0xB0, 0xE0
|
|
130
|
+
[status_byte, stream.read_byte, stream.read_byte]
|
|
131
|
+
else
|
|
132
|
+
raise InvalidSMFError, "Unknown MIDI status: #{format("0x%02X", status_byte)}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
message = Message.from_bytes(bytes, normalize_note_on_zero: false)
|
|
136
|
+
MIDIEvent.new(message: message, delta_time: delta_time, absolute_time: absolute_time)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def end_of_track?(event)
|
|
140
|
+
event.is_a?(MetaEvent) && event.type == MetaEvent::META_TYPES[:end_of_track]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private_class_method :read_header, :read_track, :parse_event,
|
|
144
|
+
:parse_meta_event, :parse_sysex_event, :parse_midi_event,
|
|
145
|
+
:end_of_track?
|
|
146
|
+
|
|
147
|
+
class StringStream
|
|
148
|
+
attr_reader :position
|
|
149
|
+
|
|
150
|
+
def initialize(data)
|
|
151
|
+
@data = data
|
|
152
|
+
@position = 0
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def read_bytes(n)
|
|
156
|
+
ensure_available!(n)
|
|
157
|
+
result = @data[@position, n]
|
|
158
|
+
@position += n
|
|
159
|
+
result
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def read_raw_bytes(n)
|
|
163
|
+
ensure_available!(n)
|
|
164
|
+
result = @data[@position, n].bytes
|
|
165
|
+
@position += n
|
|
166
|
+
result
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def read_byte
|
|
170
|
+
ensure_available!(1)
|
|
171
|
+
byte = @data.getbyte(@position)
|
|
172
|
+
@position += 1
|
|
173
|
+
byte
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def peek_byte
|
|
177
|
+
ensure_available!(1)
|
|
178
|
+
@data.getbyte(@position)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def skip(n)
|
|
182
|
+
ensure_available!(n)
|
|
183
|
+
@position += n
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def with_limit(limit)
|
|
187
|
+
@limits ||= []
|
|
188
|
+
@limits.push(limit)
|
|
189
|
+
yield
|
|
190
|
+
ensure
|
|
191
|
+
@limits.pop
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def read_uint16
|
|
195
|
+
ensure_available!(2)
|
|
196
|
+
val = (@data.getbyte(@position) << 8) | @data.getbyte(@position + 1)
|
|
197
|
+
@position += 2
|
|
198
|
+
val
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def read_uint32
|
|
202
|
+
ensure_available!(4)
|
|
203
|
+
val = (@data.getbyte(@position) << 24) |
|
|
204
|
+
(@data.getbyte(@position + 1) << 16) |
|
|
205
|
+
(@data.getbyte(@position + 2) << 8) |
|
|
206
|
+
@data.getbyte(@position + 3)
|
|
207
|
+
@position += 4
|
|
208
|
+
val
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def read_vlq
|
|
212
|
+
value = 0
|
|
213
|
+
4.times do
|
|
214
|
+
byte = read_byte
|
|
215
|
+
value = (value << 7) | (byte & 0x7F)
|
|
216
|
+
return value unless (byte & 0x80) != 0
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
raise InvalidSMFError, "VLQ exceeds 4 bytes"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
private
|
|
223
|
+
|
|
224
|
+
def ensure_available!(n)
|
|
225
|
+
limit = [@data.bytesize, current_limit].min
|
|
226
|
+
return if @position + n <= limit
|
|
227
|
+
|
|
228
|
+
raise InvalidSMFError, "Unexpected end of data at position #{@position}, need #{n} more bytes"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def current_limit
|
|
232
|
+
@limits&.last || @data.bytesize
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module SMF
|
|
5
|
+
class Sequence
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
attr_reader :format, :ppqn
|
|
9
|
+
|
|
10
|
+
def initialize(format: 1, ppqn: 480)
|
|
11
|
+
@tracks = []
|
|
12
|
+
self.format = format
|
|
13
|
+
self.ppqn = ppqn
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def tracks
|
|
17
|
+
@tracks.dup
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def add_track(track)
|
|
21
|
+
if @format == 0 && @tracks.any?
|
|
22
|
+
raise InvalidSMFError, "SMF format 0 supports exactly one track"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
@tracks << track
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def [](index)
|
|
30
|
+
@tracks[index]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def each(&block)
|
|
34
|
+
@tracks.each(&block)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def size
|
|
38
|
+
@tracks.size
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def duration
|
|
42
|
+
return 0.0 if @tracks.empty?
|
|
43
|
+
|
|
44
|
+
tempo_map = tempo_map()
|
|
45
|
+
max_ticks = @tracks.map { |t| t.events.sum(&:delta_time) }.max || 0
|
|
46
|
+
tempo_map.ticks_to_seconds(max_ticks)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def format=(format)
|
|
50
|
+
unless [0, 1].include?(format)
|
|
51
|
+
raise UnsupportedFormatError, "Only SMF format 0 and 1 are supported, got #{format.inspect}"
|
|
52
|
+
end
|
|
53
|
+
if format.zero? && defined?(@tracks) && @tracks.size > 1
|
|
54
|
+
raise InvalidSMFError, "Cannot set format 0 on a sequence with multiple tracks"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
@format = format
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def ppqn=(ppqn)
|
|
61
|
+
unless ppqn.is_a?(Integer) && ppqn.between?(1, 0x7FFF)
|
|
62
|
+
raise InvalidSMFError, "PPQN must be between 1 and 32767, got #{ppqn.inspect}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@ppqn = ppqn
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def tempo_map
|
|
69
|
+
TempoMap.from_sequence(self)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def to_format0
|
|
73
|
+
sequence = self.class.new(format: 0, ppqn: @ppqn)
|
|
74
|
+
merged = Track.new
|
|
75
|
+
events_with_time = @tracks.flat_map do |track|
|
|
76
|
+
absolute = 0
|
|
77
|
+
track.events.map do |event|
|
|
78
|
+
absolute += event.delta_time
|
|
79
|
+
[absolute, event]
|
|
80
|
+
end
|
|
81
|
+
end.sort_by(&:first)
|
|
82
|
+
|
|
83
|
+
previous = 0
|
|
84
|
+
events_with_time.each do |absolute, event|
|
|
85
|
+
merged << duplicate_event(event, delta_time: absolute - previous, absolute_time: absolute)
|
|
86
|
+
previous = absolute
|
|
87
|
+
end
|
|
88
|
+
sequence.add_track(merged)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def to_format1
|
|
92
|
+
sequence = self.class.new(format: 1, ppqn: @ppqn)
|
|
93
|
+
@tracks.each do |track|
|
|
94
|
+
copy = Track.new(name: track.name, channel: track.channel)
|
|
95
|
+
track.each do |event|
|
|
96
|
+
copy << duplicate_event(event, delta_time: event.delta_time, absolute_time: event.absolute_time)
|
|
97
|
+
end
|
|
98
|
+
sequence.add_track(copy)
|
|
99
|
+
end
|
|
100
|
+
sequence
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.read(path_or_io)
|
|
104
|
+
Reader.read(path_or_io)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.parse(binary)
|
|
108
|
+
Reader.parse(binary)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def write(path_or_io)
|
|
112
|
+
Writer.write(self, path_or_io)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def to_binary(**options)
|
|
116
|
+
Writer.to_binary(self, **options)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def duplicate_event(event, delta_time:, absolute_time:)
|
|
122
|
+
case event
|
|
123
|
+
when MIDIEvent
|
|
124
|
+
MIDIEvent.new(message: event.message, delta_time: delta_time, absolute_time: absolute_time)
|
|
125
|
+
when MetaEvent
|
|
126
|
+
MetaEvent.new(type: event.type, data: event.data, delta_time: delta_time, absolute_time: absolute_time)
|
|
127
|
+
when SysExEvent
|
|
128
|
+
SysExEvent.new(data: event.data, delta_time: delta_time, absolute_time: absolute_time)
|
|
129
|
+
else
|
|
130
|
+
raise InvalidSMFError, "Unknown SMF event type: #{event.class}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module SMF
|
|
5
|
+
class TempoMap
|
|
6
|
+
DEFAULT_TEMPO = 500_000
|
|
7
|
+
|
|
8
|
+
attr_reader :entries, :ppqn
|
|
9
|
+
|
|
10
|
+
def self.from_sequence(sequence)
|
|
11
|
+
tempo_events = []
|
|
12
|
+
sequence.each do |track|
|
|
13
|
+
tick = 0
|
|
14
|
+
track.each do |event|
|
|
15
|
+
tick += event.delta_time
|
|
16
|
+
if event.is_a?(MetaEvent) && event.type == MetaEvent::META_TYPES[:tempo]
|
|
17
|
+
tempo_events << {tick: tick, tempo: event.tempo}
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
new(tempo_events, ppqn: sequence.ppqn)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(entries = [], ppqn:)
|
|
25
|
+
raise InvalidSMFError, "PPQN must be positive" unless ppqn.is_a?(Integer) && ppqn.positive?
|
|
26
|
+
|
|
27
|
+
@ppqn = ppqn
|
|
28
|
+
@entries = entries.map { |entry| normalize_entry(entry) }.sort_by { |entry| entry[:tick] }
|
|
29
|
+
@entries.unshift({tick: 0, tempo: DEFAULT_TEMPO}) if @entries.empty? || @entries.first[:tick] != 0
|
|
30
|
+
freeze_entries!
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def ticks_to_seconds(ticks)
|
|
34
|
+
validate_non_negative_number!(ticks, "Ticks")
|
|
35
|
+
seconds = 0.0
|
|
36
|
+
current_tick = 0
|
|
37
|
+
|
|
38
|
+
@entries.each_with_index do |entry, index|
|
|
39
|
+
next_tick = if index + 1 < @entries.size
|
|
40
|
+
[@entries[index + 1][:tick], ticks].min
|
|
41
|
+
else
|
|
42
|
+
ticks
|
|
43
|
+
end
|
|
44
|
+
break if current_tick >= ticks
|
|
45
|
+
|
|
46
|
+
seconds += ticks_segment_to_seconds(next_tick - current_tick, entry[:tempo])
|
|
47
|
+
current_tick = next_tick
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
seconds
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def seconds_to_ticks(seconds)
|
|
54
|
+
validate_non_negative_number!(seconds, "Seconds")
|
|
55
|
+
remaining = seconds.to_f
|
|
56
|
+
current_tick = 0
|
|
57
|
+
|
|
58
|
+
@entries.each_with_index do |entry, index|
|
|
59
|
+
next_tick = (index + 1 < @entries.size) ? @entries[index + 1][:tick] : nil
|
|
60
|
+
segment_ticks = next_tick ? next_tick - current_tick : nil
|
|
61
|
+
seconds_per_tick = entry[:tempo] / 1_000_000.0 / @ppqn
|
|
62
|
+
|
|
63
|
+
if segment_ticks.nil?
|
|
64
|
+
return current_tick + (remaining / seconds_per_tick).round
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
segment_seconds = segment_ticks * seconds_per_tick
|
|
68
|
+
return current_tick + (remaining / seconds_per_tick).round if remaining <= segment_seconds
|
|
69
|
+
|
|
70
|
+
remaining -= segment_seconds
|
|
71
|
+
current_tick = next_tick
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def tempo_at(ticks)
|
|
76
|
+
validate_non_negative_number!(ticks, "Ticks")
|
|
77
|
+
@entries.rfind { |entry| entry[:tick] <= ticks }[:tempo]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def normalize_entry(entry)
|
|
83
|
+
tick = entry.fetch(:tick)
|
|
84
|
+
tempo = entry.fetch(:tempo)
|
|
85
|
+
raise InvalidSMFError, "Tempo map tick must be non-negative" unless tick.is_a?(Integer) && tick >= 0
|
|
86
|
+
raise InvalidSMFError, "Tempo must be positive" unless tempo.is_a?(Integer) && tempo.positive?
|
|
87
|
+
|
|
88
|
+
{tick: tick, tempo: tempo}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def freeze_entries!
|
|
92
|
+
@entries.each(&:freeze)
|
|
93
|
+
@entries.freeze
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def ticks_segment_to_seconds(ticks, tempo)
|
|
97
|
+
(ticks.to_f / @ppqn) * (tempo / 1_000_000.0)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def validate_non_negative_number!(value, name)
|
|
101
|
+
return if value.is_a?(Numeric) && value >= 0
|
|
102
|
+
|
|
103
|
+
raise InvalidSMFError, "#{name} must be non-negative, got #{value.inspect}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Webmidi
|
|
4
|
+
module SMF
|
|
5
|
+
class Track
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
NoteSpan = Struct.new(:note, :channel, :start_time, :end_time, :duration, :note_on, :note_off, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
attr_accessor :name, :channel
|
|
11
|
+
|
|
12
|
+
def initialize(name: nil, channel: nil)
|
|
13
|
+
@name = name
|
|
14
|
+
@channel = channel
|
|
15
|
+
@events = []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def events
|
|
19
|
+
@events.dup
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def add_event(event)
|
|
23
|
+
@events << event
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def <<(event)
|
|
28
|
+
add_event(event)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def each(&block)
|
|
32
|
+
@events.each(&block)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def size
|
|
36
|
+
@events.size
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def notes
|
|
40
|
+
@events.lazy.select { |e| e.is_a?(MIDIEvent) && note_event?(e.message) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def note_spans
|
|
44
|
+
active = Hash.new { |hash, key| hash[key] = [] }
|
|
45
|
+
spans = []
|
|
46
|
+
use_delta_time = @events.all? { |event| event.absolute_time.zero? }
|
|
47
|
+
tick = 0
|
|
48
|
+
|
|
49
|
+
@events.each do |event|
|
|
50
|
+
tick += event.delta_time
|
|
51
|
+
next unless event.is_a?(MIDIEvent) && note_event?(event.message)
|
|
52
|
+
|
|
53
|
+
time = use_delta_time ? tick : event.absolute_time
|
|
54
|
+
message = event.message
|
|
55
|
+
key = [message.channel, message.note]
|
|
56
|
+
|
|
57
|
+
if message.is_a?(Message::Channel::NoteOn) && message.velocity.positive?
|
|
58
|
+
active[key] << [event, time]
|
|
59
|
+
elsif (started = active[key].shift)
|
|
60
|
+
start_event, start_time = started
|
|
61
|
+
spans << NoteSpan.new(
|
|
62
|
+
note: message.note,
|
|
63
|
+
channel: message.channel,
|
|
64
|
+
start_time: start_time,
|
|
65
|
+
end_time: time,
|
|
66
|
+
duration: time - start_time,
|
|
67
|
+
note_on: start_event,
|
|
68
|
+
note_off: event
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
spans
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def control_changes
|
|
77
|
+
@events.lazy.select { |e| e.is_a?(MIDIEvent) && e.message.is_a?(Message::Channel::ControlChange) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def tempo_changes
|
|
81
|
+
@events.lazy.select { |e| e.is_a?(MetaEvent) && e.type == MetaEvent::META_TYPES[:tempo] }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def transpose(semitones)
|
|
85
|
+
new_track = Track.new(name: @name, channel: @channel)
|
|
86
|
+
@events.each do |event|
|
|
87
|
+
if event.is_a?(MIDIEvent) && note_event?(event.message)
|
|
88
|
+
msg = event.message
|
|
89
|
+
new_note = (msg.note + semitones).clamp(0, 127)
|
|
90
|
+
new_msg = msg.with(note: new_note)
|
|
91
|
+
new_track << MIDIEvent.new(message: new_msg, delta_time: event.delta_time, absolute_time: event.absolute_time)
|
|
92
|
+
else
|
|
93
|
+
new_track << event
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
new_track
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def sort_by_absolute_time!
|
|
100
|
+
@events.sort_by!(&:absolute_time)
|
|
101
|
+
self
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def recalculate_delta_times!
|
|
105
|
+
sort_by_absolute_time!
|
|
106
|
+
previous = 0
|
|
107
|
+
@events.each do |event|
|
|
108
|
+
event.delta_time = event.absolute_time - previous
|
|
109
|
+
previous = event.absolute_time
|
|
110
|
+
end
|
|
111
|
+
self
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def quantize!(grid)
|
|
115
|
+
raise InvalidSMFError, "Quantize grid must be a positive integer, got #{grid.inspect}" unless grid.is_a?(Integer) && grid.positive?
|
|
116
|
+
|
|
117
|
+
@events.each do |event|
|
|
118
|
+
event.absolute_time = ((event.absolute_time.to_f / grid).round * grid).to_i
|
|
119
|
+
end
|
|
120
|
+
recalculate_delta_times!
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def note_event?(message)
|
|
126
|
+
message.is_a?(Message::Channel::NoteOn) || message.is_a?(Message::Channel::NoteOff)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|