cyclotone 0.1.0 → 1.0.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.
@@ -1,70 +1,135 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "thread"
4
-
5
3
  module Cyclotone
6
4
  class Scheduler
7
5
  LOOKAHEAD = 0.3
8
6
  INTERVAL = 0.05
9
7
  DEFAULT_CPS = Rational(9, 16)
8
+ DEFAULT_STOP_TIMEOUT = 1.0
9
+ SENT_RETAIN_CYCLES = 4
10
+ SystemClock = Struct.new(:unused, keyword_init: true) do
11
+ def monotonic_time
12
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
13
+ end
10
14
 
11
- attr_reader :backend, :cps, :lookahead, :interval
15
+ def wall_time
16
+ Time.now.to_f
17
+ end
18
+ end
12
19
 
13
- def initialize(cps: DEFAULT_CPS, backend:, lookahead: LOOKAHEAD, interval: INTERVAL, logger: nil)
20
+ attr_reader :backend, :cps, :lookahead, :interval, :lookahead_cycles, :interval_cycles, :metrics
21
+
22
+ def initialize(
23
+ backend:,
24
+ cps: DEFAULT_CPS,
25
+ lookahead: LOOKAHEAD,
26
+ interval: INTERVAL,
27
+ lookahead_cycles: nil,
28
+ interval_cycles: nil,
29
+ logger: nil,
30
+ retry_failed: false,
31
+ clock: nil
32
+ )
14
33
  @backend = backend
15
- @cps = cps.to_f
34
+ @cps = normalize_cps(cps)
16
35
  @lookahead = lookahead
17
36
  @interval = interval
37
+ @lookahead_cycles = lookahead_cycles&.to_f
38
+ @interval_cycles = interval_cycles&.to_f
18
39
  @logger = logger
40
+ @retry_failed = retry_failed
41
+ @clock = clock || SystemClock.new
19
42
  @mutex = Mutex.new
20
43
  @patterns = {}
21
44
  @sent = {}
22
45
  @running = false
46
+ @thread = nil
47
+ @last_error = nil
23
48
  @start_monotonic = monotonic_time
24
- @start_wall_time = Time.now.to_f
49
+ @start_wall_time = wall_time
25
50
  @start_cycle = 0.0
26
51
  @last_cycle = 0.0
52
+ @metrics = { ticks: 0, last_tick_duration: 0.0, max_tick_duration: 0.0 }
27
53
  end
28
54
 
29
55
  def start
30
- return if @running
56
+ @mutex.synchronize do
57
+ return self if @running
31
58
 
32
- @running = true
33
- @thread = Thread.new do
34
- while @running
35
- begin
36
- tick
37
- rescue StandardError => error
38
- log_runtime_error(error)
39
- end
59
+ @running = true
60
+ @last_error = nil
61
+ @thread = Thread.new do
62
+ Thread.current.abort_on_exception = false
40
63
 
41
- sleep(@interval)
64
+ while running?
65
+ tick_started = monotonic_time
66
+
67
+ begin
68
+ tick(tick_started)
69
+ rescue StandardError => error
70
+ log_runtime_error(error)
71
+ end
72
+
73
+ record_tick_duration(monotonic_time - tick_started)
74
+ sleep(current_interval)
75
+ end
42
76
  end
43
77
  end
78
+
79
+ self
44
80
  end
45
81
 
46
- def stop
47
- @running = false
48
- @thread&.join
82
+ def stop(timeout: DEFAULT_STOP_TIMEOUT)
83
+ thread = @mutex.synchronize do
84
+ @running = false
85
+ @thread
86
+ end
87
+
88
+ thread&.join(timeout)
89
+
90
+ @mutex.synchronize do
91
+ @thread = nil if @thread == thread && !thread&.alive?
92
+ end
93
+
94
+ self
95
+ end
96
+
97
+ def last_error
98
+ @mutex.synchronize { @last_error }
49
99
  end
50
100
 
51
101
  def tick(now = monotonic_time)
52
102
  state = snapshot_state
53
- logical_end = time_to_cycle(now + lookahead, state[:cps], state[:start_cycle], state[:start_monotonic])
103
+ current_cycle = time_to_cycle(now, state[:cps], state[:start_cycle], state[:start_monotonic])
104
+ logical_end = if lookahead_cycles
105
+ current_cycle + lookahead_cycles
106
+ else
107
+ time_to_cycle(now + lookahead, state[:cps], state[:start_cycle], state[:start_monotonic])
108
+ end
54
109
  dispatch_until(logical_end, state)
55
110
  end
56
111
 
57
- def update_pattern(slot_id, pattern)
58
- @mutex.synchronize { @patterns[slot_id] = Pattern.ensure_pattern(pattern) }
112
+ def update_pattern(slot_id, pattern, cps: nil, phase: 0)
113
+ @mutex.synchronize do
114
+ @patterns[slot_id] = {
115
+ pattern: Pattern.ensure_pattern(pattern),
116
+ cps: cps&.to_f,
117
+ phase: Pattern.to_rational(phase)
118
+ }
119
+ end
59
120
  end
60
121
 
61
122
  def remove_pattern(slot_id)
62
- @mutex.synchronize { @patterns.delete(slot_id) }
123
+ @mutex.synchronize do
124
+ @patterns.delete(slot_id)
125
+ @sent.delete_if { |key, _| key.first == slot_id }
126
+ end
63
127
  end
64
128
 
65
129
  def setcps(value)
130
+ normalized_cps = normalize_cps(value)
66
131
  now = monotonic_time
67
- wall_now = Time.now.to_f
132
+ wall_now = wall_time
68
133
 
69
134
  @mutex.synchronize do
70
135
  current_cycle = time_to_cycle(now, @cps, @start_cycle, @start_monotonic)
@@ -72,7 +137,7 @@ module Cyclotone
72
137
  @start_cycle = current_cycle
73
138
  @start_monotonic = now
74
139
  @start_wall_time = wall_now
75
- @cps = value.to_f
140
+ @cps = normalized_cps
76
141
  end
77
142
  end
78
143
 
@@ -84,7 +149,7 @@ module Cyclotone
84
149
  @mutex.synchronize do
85
150
  @start_cycle = value.to_f
86
151
  @start_monotonic = monotonic_time
87
- @start_wall_time = Time.now.to_f
152
+ @start_wall_time = wall_time
88
153
  @last_cycle = value.to_f
89
154
  @sent.clear
90
155
  end
@@ -99,25 +164,40 @@ module Cyclotone
99
164
  end
100
165
 
101
166
  def running?
102
- @running
167
+ @mutex.synchronize { @running }
103
168
  end
104
169
 
105
- def render(duration:)
170
+ def render(duration:, at: 0.0)
106
171
  duration_value = duration.to_f
107
172
  raise ArgumentError, "render duration must be non-negative" if duration_value.negative?
108
173
 
109
- state = snapshot_state
174
+ state = snapshot_state.merge(start_wall_time: at.to_f)
110
175
  state[:backend].begin_capture(at: state[:start_wall_time]) if state[:backend].respond_to?(:begin_capture)
111
176
 
112
177
  logical_end = state[:start_cycle] + (duration_value * state[:cps])
113
178
  dispatch_until(logical_end, state)
179
+
180
+ state[:backend].end_capture if state[:backend].respond_to?(:end_capture)
181
+ state[:backend].write! if state[:backend].respond_to?(:write!)
114
182
  self
115
183
  end
116
184
 
117
185
  private
118
186
 
119
187
  def monotonic_time
120
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
188
+ @clock.respond_to?(:monotonic_time) ? @clock.monotonic_time : @clock.call(:monotonic)
189
+ end
190
+
191
+ def wall_time
192
+ @clock.respond_to?(:wall_time) ? @clock.wall_time : @clock.call(:wall)
193
+ end
194
+
195
+ def current_interval
196
+ @mutex.synchronize do
197
+ return @interval unless @interval_cycles
198
+
199
+ @interval_cycles / @cps
200
+ end
121
201
  end
122
202
 
123
203
  def snapshot_state
@@ -138,31 +218,90 @@ module Cyclotone
138
218
  return if logical_end <= state[:last_cycle]
139
219
 
140
220
  query_span = TimeSpan.new(Rational(state[:last_cycle].to_r), Rational(logical_end.to_r))
221
+ failed = false
222
+
223
+ state[:patterns].each do |slot_id, slot|
224
+ pattern = slot[:pattern]
225
+ scale = slot_scale(slot, state[:cps])
226
+ phase = slot[:phase]
227
+ slot_span = TimeSpan.new((query_span.start * scale) + phase, (query_span.stop * scale) + phase)
141
228
 
142
- state[:patterns].each do |slot_id, pattern|
143
- pattern.query_span(query_span).each do |event|
229
+ pattern.query_span(slot_span).each do |event|
230
+ event = map_slot_event(event, scale, phase)
144
231
  next unless event.onset
145
232
 
146
233
  key = [slot_id, event.onset, event.value]
147
- next if @sent[key]
234
+ next if sent?(key)
148
235
 
149
236
  absolute_time = state[:start_wall_time] + ((event.onset.to_f - state[:start_cycle]) / state[:cps])
150
- state[:backend].send_event(event, at: absolute_time, cps: state[:cps])
151
- @sent[key] = true
152
- rescue StandardError => error
153
- log_runtime_error(error)
237
+
238
+ begin
239
+ state[:backend].send_event(event, at: absolute_time, cps: state[:cps], slot_id: slot_id)
240
+ mark_sent(key, logical_end)
241
+ rescue StandardError => error
242
+ failed = true
243
+ log_runtime_error(error, slot_id: slot_id)
244
+ end
154
245
  end
155
246
  end
156
247
 
157
- @mutex.synchronize { @last_cycle = logical_end }
248
+ @mutex.synchronize { @last_cycle = logical_end unless failed && @retry_failed }
249
+ end
250
+
251
+ def sent?(key)
252
+ @mutex.synchronize { @sent.key?(key) }
253
+ end
254
+
255
+ def mark_sent(key, logical_end)
256
+ @mutex.synchronize do
257
+ @sent[key] = logical_end
258
+ prune_sent(logical_end)
259
+ end
260
+ end
261
+
262
+ def prune_sent(logical_end)
263
+ retain_after = logical_end - SENT_RETAIN_CYCLES
264
+ @sent.delete_if { |_key, cycle| cycle < retain_after }
265
+ end
266
+
267
+ def record_tick_duration(duration)
268
+ @mutex.synchronize do
269
+ @metrics = {
270
+ ticks: @metrics[:ticks] + 1,
271
+ last_tick_duration: duration,
272
+ max_tick_duration: [@metrics[:max_tick_duration], duration].max
273
+ }
274
+ end
275
+ end
276
+
277
+ def slot_scale(slot, global_cps)
278
+ slot_cps = slot[:cps]
279
+ return Rational(1) unless slot_cps&.positive?
280
+
281
+ Rational(slot_cps.to_r) / Rational(global_cps.to_r)
282
+ end
283
+
284
+ def map_slot_event(event, scale, phase)
285
+ return event if scale == 1 && phase.zero?
286
+
287
+ Pattern.map_event(event) { |time| (time - phase) / scale }
288
+ end
289
+
290
+ def normalize_cps(value)
291
+ normalized = value.to_f
292
+ raise ArgumentError, "cps must be positive" unless normalized.positive?
293
+
294
+ normalized
158
295
  end
159
296
 
160
297
  def time_to_cycle(time, cps_value, start_cycle, start_monotonic)
161
298
  start_cycle + ((time - start_monotonic) * cps_value)
162
299
  end
163
300
 
164
- def log_runtime_error(error)
165
- @logger&.call("[Cyclotone::Scheduler] #{error.class}: #{error.message}")
301
+ def log_runtime_error(error, slot_id: nil)
302
+ @mutex.synchronize { @last_error = error }
303
+ slot = slot_id ? " slot=#{slot_id}" : ""
304
+ @logger&.call("[Cyclotone::Scheduler#{slot}] #{error.class}: #{error.message}")
166
305
  end
167
306
  end
168
307
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "singleton"
4
- require "thread"
5
4
 
6
5
  module Cyclotone
7
6
  class State
@@ -1,34 +1,60 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "singleton"
4
3
  require "set"
5
4
 
6
5
  module Cyclotone
7
6
  class Stream
8
- include Singleton
9
7
  include Transition
10
8
 
11
- attr_reader :scheduler
9
+ class << self
10
+ def instance
11
+ @instance ||= new
12
+ end
13
+ end
14
+
15
+ attr_reader :scheduler, :fallback_error
12
16
 
13
- def initialize
17
+ NullBackend = Backends::NullBackend
18
+
19
+ def initialize(backend: nil, scheduler: nil)
14
20
  @slots = {}
21
+ @slot_options = {}
22
+ @transitions = {}
15
23
  @muted = Set.new
16
24
  @soloed = Set.new
17
- @scheduler = Scheduler.new(backend: Backends::OSCBackend.new(socket: UDPSocket.new))
18
- rescue StandardError
19
- @scheduler = Scheduler.new(backend: NullBackend.new)
20
- end
21
-
22
- def d(slot_id, pattern)
23
- assign(normalize_d_slot_id(slot_id), pattern)
24
- end
25
-
26
- def p(name, pattern)
27
- assign(normalize_slot_reference(name), pattern)
28
- end
25
+ @fallback_error = nil
26
+ @scheduler = scheduler || Scheduler.new(backend: backend || default_backend)
27
+ rescue StandardError => error
28
+ @fallback_error = error
29
+ @scheduler = Scheduler.new(backend: Backends::NullBackend.new)
30
+ end
31
+
32
+ def d(slot_id, pattern, cps: nil, phase: 0)
33
+ assign(normalize_d_slot_id(slot_id), pattern, cps: cps, phase: phase)
34
+ end
35
+
36
+ def p(name, pattern, cps: nil, phase: 0)
37
+ assign(normalize_slot_reference(name), pattern, cps: cps, phase: phase)
38
+ end
39
+
40
+ def hush(mode: :silence)
41
+ case mode
42
+ when :silence
43
+ @slots.each_key { |slot_id| assign(slot_id, Pattern.silence) }
44
+ when :mute
45
+ @muted.merge(@slots.keys)
46
+ sync_scheduler
47
+ when :clear
48
+ @slots.each_key { |slot_id| @scheduler.remove_pattern(slot_id) }
49
+ @slots.clear
50
+ @slot_options.clear
51
+ @transitions.clear
52
+ @muted.clear
53
+ @soloed.clear
54
+ else
55
+ raise ArgumentError, "unknown hush mode #{mode}"
56
+ end
29
57
 
30
- def hush
31
- @slots.keys.each { |slot_id| assign(slot_id, Pattern.silence) }
32
58
  self
33
59
  end
34
60
 
@@ -95,7 +121,9 @@ module Cyclotone
95
121
  end
96
122
 
97
123
  def mtrigger(period)
98
- normalized_period = [period.to_i, 1].max
124
+ normalized_period = period.to_i
125
+ raise ArgumentError, "mtrigger period must be positive" unless normalized_period.positive?
126
+
99
127
  current_cycle = @scheduler.current_cycle
100
128
  next_cycle = current_cycle.ceil
101
129
  remainder = next_cycle % normalized_period
@@ -105,43 +133,71 @@ module Cyclotone
105
133
  end
106
134
 
107
135
  def slot(slot_id)
108
- @slots[normalize_slot_reference(slot_id)]
136
+ normalized = normalize_slot_reference(slot_id)
137
+ simplify_completed_transition(normalized)
138
+ @slots[normalized]
109
139
  end
110
140
 
111
141
  private
112
142
 
113
- def assign(slot_id, pattern)
143
+ def assign(slot_id, pattern, cps: nil, phase: 0)
114
144
  @slots[slot_id] = normalize_pattern(pattern)
145
+ @slot_options[slot_id] = { cps: cps, phase: phase }
146
+ @transitions.delete(slot_id)
115
147
  sync_scheduler
116
148
  @slots[slot_id]
117
149
  end
118
150
 
119
- def normalize_pattern(pattern)
120
- return pattern if pattern.is_a?(Pattern)
121
- return Pattern.mn(pattern) if pattern.is_a?(String)
151
+ def assign_transition(slot_id, pattern, replacement:, finish_cycle:)
152
+ @slots[slot_id] = normalize_pattern(pattern)
153
+ @transitions[slot_id] = { replacement: replacement, finish_cycle: finish_cycle }
154
+ sync_scheduler
155
+ @slots[slot_id]
156
+ end
122
157
 
123
- Pattern.pure(pattern)
158
+ def pattern_for_transition(slot_id)
159
+ simplify_completed_transition(slot_id)
160
+ @slots[slot_id] || Pattern.silence
124
161
  end
125
162
 
126
- def normalize_d_slot_id(slot_id)
127
- raw = slot_id.to_s
128
- return raw.to_sym if raw.match?(/\Ad\d+\z/)
129
- return :"d#{raw}" if raw.match?(/\A\d+\z/)
163
+ def simplify_completed_transition(slot_id)
164
+ transition = @transitions[slot_id]
165
+ return unless transition
166
+ return if @scheduler.current_cycle < transition[:finish_cycle].to_f
167
+
168
+ @slots[slot_id] = transition[:replacement]
169
+ @transitions.delete(slot_id)
170
+ sync_scheduler
171
+ end
172
+
173
+ def normalize_pattern(pattern)
174
+ Pattern.ensure_pattern(pattern, strings: :mini_notation)
175
+ end
130
176
 
131
- :"d#{raw}"
177
+ def normalize_d_slot_id(slot_id)
178
+ normalize_slot_id(slot_id, force_d: true)
132
179
  end
133
180
 
134
181
  def normalize_slot_reference(slot_id)
182
+ normalize_slot_id(slot_id, force_d: false)
183
+ end
184
+
185
+ def normalize_slot_id(slot_id, force_d:)
135
186
  raw = slot_id.to_s
136
187
  return raw.to_sym if raw.match?(/\Ad\d+\z/)
137
188
  return :"d#{raw}" if raw.match?(/\A\d+\z/)
138
189
 
139
- raw.to_sym
190
+ force_d ? :"d#{raw}" : raw.to_sym
191
+ end
192
+
193
+ def default_backend
194
+ Backends::OSCBackend.new(socket: UDPSocket.new)
140
195
  end
141
196
 
142
197
  def sync_scheduler
143
198
  active_slots.each do |slot_id, pattern|
144
- @scheduler.update_pattern(slot_id, pattern)
199
+ options = @slot_options.fetch(slot_id, {})
200
+ @scheduler.update_pattern(slot_id, pattern, cps: options[:cps], phase: options[:phase] || 0)
145
201
  end
146
202
 
147
203
  inactive_slots.each do |slot_id|
@@ -153,7 +209,7 @@ module Cyclotone
153
209
 
154
210
  def active_slots
155
211
  if @soloed.empty?
156
- @slots.reject { |slot_id, _| @muted.include?(slot_id) }
212
+ @slots.except(*@muted)
157
213
  else
158
214
  @slots.select { |slot_id, _| @soloed.include?(slot_id) && !@muted.include?(slot_id) }
159
215
  end
@@ -164,22 +220,12 @@ module Cyclotone
164
220
  end
165
221
 
166
222
  def wait_until_cycle(target_cycle)
223
+ return self unless @scheduler.running?
224
+
167
225
  cycles_remaining = target_cycle.to_f - @scheduler.current_cycle
168
226
  seconds = cycles_remaining / @scheduler.cps
169
227
  sleep(seconds) if seconds.positive?
170
228
  self
171
229
  end
172
-
173
- class NullBackend
174
- attr_reader :events
175
-
176
- def initialize
177
- @events = []
178
- end
179
-
180
- def send_event(event, at:, **options)
181
- @events << { event: event, at: at, options: options }
182
- end
183
- end
184
230
  end
185
231
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  module Cyclotone
4
6
  module Support
5
7
  module Deterministic
@@ -7,10 +9,14 @@ module Cyclotone
7
9
 
8
10
  def seed_for(*parts)
9
11
  parts.flatten.compact.reduce(17) do |memo, part|
10
- ((memo * 31) ^ normalize(part).hash) & 0xFFFFFFFF
12
+ ((memo * 31) ^ stable_hash(normalize(part))) & 0xFFFFFFFF
11
13
  end
12
14
  end
13
15
 
16
+ def canonical_key(value)
17
+ canonical_dump(normalize(value))
18
+ end
19
+
14
20
  def random(*parts)
15
21
  Random.new(seed_for(*parts))
16
22
  end
@@ -37,6 +43,36 @@ module Cyclotone
37
43
  value
38
44
  end
39
45
  end
46
+
47
+ private_class_method def self.stable_hash(value)
48
+ Digest::SHA256.digest(canonical_dump(value)).unpack1("N")
49
+ end
50
+
51
+ private_class_method def self.canonical_dump(value)
52
+ case value
53
+ when NilClass
54
+ "nil"
55
+ when TrueClass, FalseClass
56
+ "bool:#{value}"
57
+ when Integer
58
+ "int:#{value}"
59
+ when Float
60
+ "float:#{value}"
61
+ when Symbol
62
+ "sym:#{value}"
63
+ when String
64
+ "str:#{value.bytesize}:#{value}"
65
+ when Array
66
+ "arr:[#{value.map { |entry| canonical_dump(entry) }.join(",")}]"
67
+ when Hash
68
+ entries = value.sort_by { |key, _| canonical_dump(key) }.map do |key, entry|
69
+ "#{canonical_dump(key)}=>#{canonical_dump(entry)}"
70
+ end
71
+ "hash:{#{entries.join(",")}}"
72
+ else
73
+ "#{value.class.name}:#{value.inspect}"
74
+ end
75
+ end
40
76
  end
41
77
  end
42
78
  end
@@ -26,8 +26,9 @@ module Cyclotone
26
26
  end
27
27
 
28
28
  def intersection(other)
29
- intersection_start = [start, other.start].max
30
- intersection_stop = [stop, other.stop].min
29
+ other_span = coerce_span(other)
30
+ intersection_start = [start, other_span.start].max
31
+ intersection_stop = [stop, other_span.stop].min
31
32
 
32
33
  return nil if intersection_start >= intersection_stop
33
34
 
@@ -39,19 +40,27 @@ module Cyclotone
39
40
  start <= normalized_time && normalized_time < stop
40
41
  end
41
42
 
42
- def cycle_spans
43
- return [] if duration.zero?
43
+ def each_cycle_span(max_cycles: nil)
44
+ return enum_for(:each_cycle_span, max_cycles: max_cycles) unless block_given?
45
+ return self if duration.zero?
44
46
 
45
- spans = []
46
47
  current_start = start
48
+ emitted = 0
47
49
 
48
50
  while current_start < stop
51
+ raise ArgumentError, "span crosses more than #{max_cycles} cycles" if max_cycles && emitted >= max_cycles
52
+
49
53
  cycle_boundary = [Rational(current_start.floor + 1), stop].min
50
- spans << self.class.new(current_start, cycle_boundary)
54
+ yield self.class.new(current_start, cycle_boundary)
51
55
  current_start = cycle_boundary
56
+ emitted += 1
52
57
  end
53
58
 
54
- spans
59
+ self
60
+ end
61
+
62
+ def cycle_spans(max_cycles: nil)
63
+ each_cycle_span(max_cycles: max_cycles).to_a
55
64
  end
56
65
 
57
66
  def shift(amount)
@@ -62,6 +71,7 @@ module Cyclotone
62
71
 
63
72
  def scale(factor)
64
73
  normalized_factor = coerce_time(factor)
74
+ raise ArgumentError, "scale factor must be non-negative" if normalized_factor.negative?
65
75
 
66
76
  self.class.new(start * normalized_factor, stop * normalized_factor)
67
77
  end
@@ -69,6 +79,8 @@ module Cyclotone
69
79
  def reverse_within(cycle_start, cycle_length = 1)
70
80
  normalized_start = coerce_time(cycle_start)
71
81
  normalized_length = coerce_time(cycle_length)
82
+ raise ArgumentError, "cycle length must be positive" unless normalized_length.positive?
83
+
72
84
  mirror = (normalized_start * 2) + normalized_length
73
85
 
74
86
  self.class.new(mirror - stop, mirror - start)
@@ -92,8 +104,18 @@ module Cyclotone
92
104
 
93
105
  def coerce_time(value)
94
106
  return value if value.is_a?(Rational)
107
+ return Rational(value.to_s) if value.is_a?(Float)
95
108
 
96
109
  Rational(value)
110
+ rescue ArgumentError, TypeError => error
111
+ raise ArgumentError, "invalid time value #{value.inspect}: #{error.message}"
112
+ end
113
+
114
+ def coerce_span(value)
115
+ return value if value.is_a?(self.class)
116
+ return self.class.new(value.fetch(0), value.fetch(1)) if value.respond_to?(:fetch)
117
+
118
+ raise ArgumentError, "expected #{self.class}, got #{value.class}"
97
119
  end
98
120
  end
99
121
  end