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,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