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.
@@ -8,11 +8,16 @@ module Cyclotone
8
8
  end
9
9
 
10
10
  def superimpose(&block)
11
+ raise ArgumentError, "superimpose requires a block" unless block
12
+
11
13
  overlay(block.call(self))
12
14
  end
13
15
 
14
16
  def layer(functions)
15
- Pattern.stack(functions.map { |function| function.call(self) })
17
+ normalized_functions = Array(functions)
18
+ raise ArgumentError, "layer requires functions" if normalized_functions.empty?
19
+
20
+ Pattern.stack(normalized_functions.map { |function| function.call(self) })
16
21
  end
17
22
 
18
23
  def jux(&block)
@@ -20,8 +25,11 @@ module Cyclotone
20
25
  end
21
26
 
22
27
  def jux_by(amount, &block)
23
- left = merge(Controls.pan(0.5 - (amount.to_f / 2.0)))
24
- right = block.call(self).merge(Controls.pan(0.5 + (amount.to_f / 2.0)))
28
+ raise ArgumentError, "jux_by requires a block" unless block
29
+
30
+ offset = amount.to_f / 2.0
31
+ left = merge(Controls.pan((0.5 - offset).clamp(0.0, 1.0)))
32
+ right = block.call(self).merge(Controls.pan((0.5 + offset).clamp(0.0, 1.0)))
25
33
  Pattern.stack([left, right])
26
34
  end
27
35
 
@@ -34,12 +42,27 @@ module Cyclotone
34
42
  end
35
43
 
36
44
  def weave_with(count, pattern, functions)
45
+ normalized_count = normalize_weave_count(count)
46
+ normalized_functions = Array(functions)
47
+ raise ArgumentError, "weave functions must not be empty" if normalized_functions.empty?
48
+
37
49
  Pattern.fastcat(
38
- Array.new(count) do |index|
39
- functions[index % functions.length].call(pattern)
50
+ Array.new(normalized_count) do |index|
51
+ normalized_functions[index % normalized_functions.length].call(pattern)
40
52
  end
41
53
  )
42
54
  end
55
+
56
+ private
57
+
58
+ def normalize_weave_count(count)
59
+ normalized_count = Integer(count)
60
+ raise ArgumentError, "weave count must be positive" unless normalized_count.positive?
61
+
62
+ normalized_count
63
+ rescue ArgumentError, TypeError => error
64
+ raise ArgumentError, "invalid weave count: #{error.message}"
65
+ end
43
66
  end
44
67
  end
45
68
  end
@@ -3,13 +3,19 @@
3
3
  module Cyclotone
4
4
  module Transforms
5
5
  module Alteration
6
+ MAX_SEGMENTS = 4096
7
+
6
8
  def every(period, &block)
7
9
  every_with_offset(period, 0, &block)
8
10
  end
9
11
 
10
12
  def every_with_offset(period, offset, &block)
13
+ normalized_period = validate_positive_rational(period, "every period")
14
+ normalized_offset = Pattern.to_rational(offset)
15
+ raise ArgumentError, "every requires a block" unless block
16
+
11
17
  Pattern.new do |span|
12
- if ((span.cycle_number - offset) % period).zero?
18
+ if ((span.cycle_number - normalized_offset) % normalized_period).zero?
13
19
  block.call(self).query_span(span)
14
20
  else
15
21
  query_span(span)
@@ -27,9 +33,12 @@ module Cyclotone
27
33
  sometimes_by(0.5, &block)
28
34
  end
29
35
 
30
- def sometimes_by(probability, &block)
36
+ def sometimes_by(probability, namespace: :sometimes, &block)
37
+ normalized_probability = validate_probability(probability, "sometimes probability")
38
+ raise ArgumentError, "sometimes_by requires a block" unless block
39
+
31
40
  Pattern.new do |span|
32
- if Support::Deterministic.float(:sometimes, probability, span.cycle_number) < probability.to_f
41
+ if Support::Deterministic.float(namespace, normalized_probability, span.cycle_number) < normalized_probability
33
42
  block.call(self).query_span(span)
34
43
  else
35
44
  query_span(span)
@@ -50,10 +59,13 @@ module Cyclotone
50
59
  end
51
60
 
52
61
  def chunk(count, &block)
62
+ normalized_count = validate_segment_count(count, "chunk count")
63
+ raise ArgumentError, "chunk requires a block" unless block
64
+
53
65
  Pattern.new do |span|
54
- selected = span.cycle_number % count
55
- pieces = Array.new(count) do |index|
56
- segment = zoom(Rational(index, count), Rational(index + 1, count))
66
+ selected = span.cycle_number % normalized_count
67
+ pieces = Array.new(normalized_count) do |index|
68
+ segment = zoom(Rational(index, normalized_count), Rational(index + 1, normalized_count))
57
69
  index == selected ? block.call(segment) : segment
58
70
  end
59
71
 
@@ -62,8 +74,10 @@ module Cyclotone
62
74
  end
63
75
 
64
76
  def scramble(count)
65
- reorder_segments(count) do |cycle|
66
- Array.new(count) { |index| index }.shuffle(random: Support::Deterministic.random(:scramble, cycle))
77
+ normalized_count = validate_segment_count(count, "scramble count")
78
+
79
+ reorder_segments(normalized_count) do |cycle|
80
+ Array.new(normalized_count) { |index| index }.shuffle(random: Support::Deterministic.random(:scramble, cycle))
67
81
  end
68
82
  end
69
83
 
@@ -72,22 +86,28 @@ module Cyclotone
72
86
  end
73
87
 
74
88
  def iter(count)
75
- reorder_segments(count) do |cycle|
76
- Array.new(count) { |index| (index + cycle) % count }
89
+ normalized_count = validate_segment_count(count, "iter count")
90
+
91
+ reorder_segments(normalized_count) do |cycle|
92
+ Array.new(normalized_count) { |index| (index + cycle) % normalized_count }
77
93
  end
78
94
  end
79
95
 
80
96
  def iter_back(count)
81
- reorder_segments(count) do |cycle|
82
- Array.new(count) { |index| (index - cycle) % count }
97
+ normalized_count = validate_segment_count(count, "iter_back count")
98
+
99
+ reorder_segments(normalized_count) do |cycle|
100
+ Array.new(normalized_count) { |index| (index - cycle) % normalized_count }
83
101
  end
84
102
  end
85
103
 
86
104
  def degrade_by(probability)
105
+ normalized_probability = validate_probability(probability, "degrade probability")
106
+
87
107
  select_events do |event|
88
108
  cycle = (event.onset || event.part.start).floor
89
- seed = [:degrade, probability, cycle, event.value, event.part.start]
90
- Support::Deterministic.float(seed) >= probability.to_f
109
+ seed = [:degrade, normalized_probability, cycle, event.value, event.part.start]
110
+ Support::Deterministic.float(seed) >= normalized_probability
91
111
  end
92
112
  end
93
113
 
@@ -97,6 +117,7 @@ module Cyclotone
97
117
 
98
118
  def trunc(amount)
99
119
  limit = Pattern.to_rational(amount)
120
+ raise ArgumentError, "trunc amount must be between 0 and 1" if limit.negative? || limit > 1
100
121
 
101
122
  Pattern.new do |span|
102
123
  cycle_start = Rational(span.cycle_number)
@@ -123,6 +144,7 @@ module Cyclotone
123
144
  window_start = Pattern.to_rational(start_point)
124
145
  window_end = Pattern.to_rational(end_point)
125
146
  window_length = window_end - window_start
147
+ raise ArgumentError, "zoom end must be greater than start" unless window_length.positive?
126
148
 
127
149
  Pattern.new do |span|
128
150
  cycle_start = Rational(span.cycle_number)
@@ -148,26 +170,68 @@ module Cyclotone
148
170
  end
149
171
 
150
172
  def spread(function, values)
151
- sequence = Pattern.cat(Array(values).map { |value| function.call(self, value) })
173
+ normalized_values = validate_values(values, "spread values")
174
+ raise ArgumentError, "spread requires a callable function" unless function.respond_to?(:call)
175
+
176
+ sequence = Pattern.cat(normalized_values.map { |value| function.call(self, value) })
152
177
  Pattern.new { |span| sequence.query_span(span) }
153
178
  end
154
179
 
155
180
  def fastspread(function, values)
156
- Pattern.fastcat(Array(values).map { |value| function.call(self, value) })
181
+ normalized_values = validate_values(values, "fastspread values")
182
+ raise ArgumentError, "fastspread requires a callable function" unless function.respond_to?(:call)
183
+
184
+ Pattern.fastcat(normalized_values.map { |value| function.call(self, value) })
157
185
  end
158
186
 
159
187
  private
160
188
 
161
189
  def reorder_segments(count)
190
+ normalized_count = validate_segment_count(count, "segment count")
191
+
162
192
  Pattern.new do |span|
163
193
  order = yield(span.cycle_number)
164
- segment_patterns = Array.new(count) do |index|
165
- zoom(Rational(order[index], count), Rational(order[index] + 1, count))
194
+ segment_patterns = Array.new(normalized_count) do |index|
195
+ zoom(Rational(order[index], normalized_count), Rational(order[index] + 1, normalized_count))
166
196
  end
167
197
 
168
198
  Pattern.fastcat(segment_patterns).query_span(span)
169
199
  end
170
200
  end
201
+
202
+ def validate_positive_rational(value, label)
203
+ normalized = Pattern.to_rational(value)
204
+ raise ArgumentError, "#{label} must be positive" unless normalized.positive?
205
+
206
+ normalized
207
+ end
208
+
209
+ def validate_probability(value, label)
210
+ normalized = Float(value)
211
+ raise ArgumentError, "#{label} must be finite" unless normalized.finite?
212
+ raise ArgumentError, "#{label} must be between 0 and 1" unless normalized.between?(0.0, 1.0)
213
+
214
+ normalized
215
+ rescue ArgumentError, TypeError => error
216
+ raise ArgumentError, "invalid #{label}: #{error.message}"
217
+ end
218
+
219
+ def validate_segment_count(value, label)
220
+ normalized = Integer(value)
221
+ raise ArgumentError, "#{label} must be positive" unless normalized.positive?
222
+ raise ArgumentError, "#{label} must be <= #{MAX_SEGMENTS}" if normalized > MAX_SEGMENTS
223
+
224
+ normalized
225
+ rescue ArgumentError, TypeError => error
226
+ raise ArgumentError, "invalid #{label}: #{error.message}"
227
+ end
228
+
229
+ def validate_values(values, label)
230
+ normalized = Array(values)
231
+ raise ArgumentError, "#{label} must not be empty" if normalized.empty?
232
+
233
+ normalized
234
+ end
171
235
  end
172
236
  end
173
237
  end
@@ -4,12 +4,18 @@ module Cyclotone
4
4
  module Transforms
5
5
  module Condition
6
6
  def when_mod(period, minimum, &block)
7
+ normalized_period = Pattern.to_rational(period)
8
+ raise ArgumentError, "when_mod period must be positive" unless normalized_period.positive?
9
+ raise ArgumentError, "when_mod requires a block" unless block
10
+
7
11
  Pattern.new do |span|
8
- (span.cycle_number % period) >= minimum ? block.call(self).query_span(span) : query_span(span)
12
+ (span.cycle_number % normalized_period) >= Pattern.to_rational(minimum) ? block.call(self).query_span(span) : query_span(span)
9
13
  end
10
14
  end
11
15
 
12
16
  def fix(control_pattern, &block)
17
+ raise ArgumentError, "fix requires a block" unless block
18
+
13
19
  transformed = block.call(self)
14
20
 
15
21
  Pattern.new do |span|
@@ -25,10 +31,15 @@ module Cyclotone
25
31
  end
26
32
 
27
33
  def unfix(control_pattern, &block)
34
+ raise ArgumentError, "unfix requires a block" unless block
35
+
28
36
  contrast(block, proc { |pattern| pattern }, control_pattern)
29
37
  end
30
38
 
31
39
  def contrast(true_function, false_function, control_pattern)
40
+ raise ArgumentError, "contrast requires a true function" unless true_function.respond_to?(:call)
41
+ raise ArgumentError, "contrast requires a false function" unless false_function.respond_to?(:call)
42
+
32
43
  true_pattern = true_function.call(self)
33
44
  false_pattern = false_function.call(self)
34
45
 
@@ -49,14 +60,15 @@ module Cyclotone
49
60
 
50
61
  def struct(bool_pattern)
51
62
  Pattern.ensure_pattern(bool_pattern).combine_left(self) do |gate, value|
52
- gate ? value : nil
63
+ truthy?(gate) ? value : nil
53
64
  end.select_events { |event| !event.value.nil? }
54
65
  end
55
66
 
56
67
  private
57
68
 
58
69
  def truthy?(value)
59
- !(value.nil? || value == false || value == 0 || value == 0.0 || value == {})
70
+ falsey_number = value.is_a?(Numeric) && value.zero?
71
+ !(value.nil? || value == false || falsey_number || value == {})
60
72
  end
61
73
  end
62
74
  end
@@ -4,9 +4,13 @@ module Cyclotone
4
4
  module Transforms
5
5
  module Sample
6
6
  def chop(count)
7
+ normalized_count = validate_positive_integer(count, "chop count")
8
+
7
9
  Pattern.fastcat(
8
- Array.new(count) do |index|
9
- merge(Controls.begin(index.to_f / count)).merge(Controls.end((index + 1).to_f / count))
10
+ Array.new(normalized_count) do |index|
11
+ merge(Controls.begin(Rational(index, normalized_count))).merge(
12
+ Controls.end(Rational(index + 1, normalized_count))
13
+ )
10
14
  end
11
15
  )
12
16
  end
@@ -16,11 +20,15 @@ module Cyclotone
16
20
  end
17
21
 
18
22
  def slice(count, pattern)
23
+ normalized_count = validate_positive_integer(count, "slice count")
19
24
  selection = Pattern.ensure_pattern(pattern)
20
25
 
21
26
  map_events do |event|
22
- index = selection.query_point(event.onset || event.part.start).to_i % count
23
- merge_controls(event, begin: index.to_f / count, end: (index + 1).to_f / count)
27
+ selected_value = selection.query_point(event.onset || event.part.start)
28
+ next nil if selected_value.nil?
29
+
30
+ index = selected_value.to_i % normalized_count
31
+ merge_controls(event, begin: Rational(index, normalized_count), end: Rational(index + 1, normalized_count))
24
32
  end
25
33
  end
26
34
 
@@ -37,22 +45,29 @@ module Cyclotone
37
45
  end
38
46
 
39
47
  def randslice(count)
48
+ normalized_count = validate_positive_integer(count, "randslice count")
49
+
40
50
  map_events do |event|
41
- index = Support::Deterministic.int(count, :randslice, event.value, event.part.start)
42
- merge_controls(event, begin: index.to_f / count, end: (index + 1).to_f / count)
51
+ index = Support::Deterministic.int(normalized_count, :randslice, event.value, event.part.start)
52
+ merge_controls(event, begin: Rational(index, normalized_count), end: Rational(index + 1, normalized_count))
43
53
  end
44
54
  end
45
55
 
46
56
  def loop_at(cycles)
47
- merge(Controls.speed(1.0 / cycles.to_f))
57
+ normalized_cycles = Pattern.to_rational(cycles)
58
+ raise ArgumentError, "loop_at cycles must be positive" unless normalized_cycles.positive?
59
+
60
+ merge(Controls.speed(1.0 / normalized_cycles.to_f))
48
61
  end
49
62
 
50
63
  def segment(count)
64
+ normalized_count = validate_positive_integer(count, "segment count")
65
+
51
66
  Pattern.new do |span|
52
67
  cycle_start = Rational(span.cycle_number)
53
- segment_length = Rational(1, count)
68
+ segment_length = Rational(1, normalized_count)
54
69
 
55
- Array.new(count) { |index| index }.filter_map do |index|
70
+ Array.new(normalized_count) { |index| index }.filter_map do |index|
56
71
  segment_span = TimeSpan.new(
57
72
  cycle_start + (segment_length * index),
58
73
  cycle_start + (segment_length * (index + 1))
@@ -77,6 +92,15 @@ module Cyclotone
77
92
 
78
93
  event.with_value(merged_value)
79
94
  end
95
+
96
+ def validate_positive_integer(value, label)
97
+ normalized = Integer(value)
98
+ raise ArgumentError, "#{label} must be positive" unless normalized.positive?
99
+
100
+ normalized
101
+ rescue ArgumentError, TypeError => error
102
+ raise ArgumentError, "invalid #{label}: #{error.message}"
103
+ end
80
104
  end
81
105
  end
82
106
  end
@@ -5,6 +5,7 @@ module Cyclotone
5
5
  module Time
6
6
  def fast(amount)
7
7
  factor = Pattern.to_rational(amount)
8
+ raise ArgumentError, "fast amount must be positive" unless factor.positive?
8
9
 
9
10
  Pattern.new do |span|
10
11
  source_span = TimeSpan.new(span.start * factor, span.stop * factor)
@@ -16,7 +17,10 @@ module Cyclotone
16
17
  end
17
18
 
18
19
  def slow(amount)
19
- fast(Rational(1, 1) / Pattern.to_rational(amount))
20
+ factor = Pattern.to_rational(amount)
21
+ raise ArgumentError, "slow amount must be positive" unless factor.positive?
22
+
23
+ fast(Rational(1, 1) / factor)
20
24
  end
21
25
 
22
26
  def early(amount)
@@ -57,35 +61,50 @@ module Cyclotone
57
61
  fast(amount).fmap do |value|
58
62
  next value unless value.is_a?(Hash)
59
63
 
60
- current_speed = value[:speed] || 1.0
64
+ current_speed = value.fetch(:speed, 1.0) || 1.0
61
65
  value.merge(speed: current_speed * amount.to_f)
62
66
  end
63
67
  end
64
68
 
65
69
  def off(amount, &block)
70
+ raise ArgumentError, "off requires a block" unless block
71
+
66
72
  transformed = block.call(self).early(amount)
67
73
  Pattern.stack([self, transformed])
68
74
  end
69
75
 
70
76
  def swing(amount, div = 4)
71
- step_shift = Pattern.to_rational(amount) / Pattern.to_rational(div)
77
+ normalized_div = Pattern.to_rational(div)
78
+ raise ArgumentError, "swing division must be positive" unless normalized_div.positive?
79
+
80
+ step_shift = Pattern.to_rational(amount) / normalized_div
72
81
 
73
82
  map_events do |event|
74
83
  next event unless event.onset
75
84
 
76
85
  cycle_start = Rational(event.onset.floor)
77
- step_index = (((event.onset - cycle_start) * div).floor).to_i
86
+ step_index = ((event.onset - cycle_start) * div).floor.to_i
78
87
  next event if step_index.even?
79
88
 
80
- Pattern.shift_event(event, step_shift)
89
+ shifted = Pattern.shift_event(event, step_shift)
90
+ cycle_span = TimeSpan.new(cycle_start, cycle_start + 1)
91
+ clipped_part = shifted.part.intersection(cycle_span)
92
+ next nil unless clipped_part
93
+
94
+ clipped_whole = shifted.whole&.intersection(cycle_span) || shifted.whole
95
+ shifted.with_span(part: clipped_part, whole: clipped_whole)
81
96
  end
82
97
  end
83
98
 
84
99
  def inside(amount, &block)
100
+ raise ArgumentError, "inside requires a block" unless block
101
+
85
102
  slow(amount).then(&block).fast(amount)
86
103
  end
87
104
 
88
105
  def outside(amount, &block)
106
+ raise ArgumentError, "outside requires a block" unless block
107
+
89
108
  fast(amount).then(&block).slow(amount)
90
109
  end
91
110
  end
@@ -10,19 +10,21 @@ module Cyclotone
10
10
 
11
11
  def xfade_in(id, cycles, pattern)
12
12
  slot_id = normalize_slot_reference(id)
13
- current = @slots[slot_id] || Pattern.silence
13
+ current = pattern_for_transition(slot_id)
14
14
  replacement = Pattern.ensure_pattern(pattern)
15
15
  duration = Pattern.to_rational(cycles)
16
16
  return assign(slot_id, replacement) if duration <= 0
17
17
 
18
18
  start_cycle = transition_start_cycle
19
19
 
20
- mixed = Pattern.stack([
21
- apply_gain_envelope(current, start_cycle: start_cycle, duration: duration, direction: :out),
22
- apply_gain_envelope(replacement, start_cycle: start_cycle, duration: duration, direction: :in)
23
- ])
20
+ mixed = Pattern.stack(
21
+ [
22
+ apply_gain_envelope(current, start_cycle: start_cycle, duration: duration, direction: :out),
23
+ apply_gain_envelope(replacement, start_cycle: start_cycle, duration: duration, direction: :in)
24
+ ]
25
+ )
24
26
 
25
- assign(slot_id, mixed)
27
+ assign_transition(slot_id, mixed, replacement: replacement, finish_cycle: start_cycle + duration)
26
28
  end
27
29
 
28
30
  def clutch(id, pattern)
@@ -31,7 +33,7 @@ module Cyclotone
31
33
 
32
34
  def clutch_in(id, cycles, pattern)
33
35
  slot_id = normalize_slot_reference(id)
34
- current = @slots[slot_id] || Pattern.silence
36
+ current = pattern_for_transition(slot_id)
35
37
  replacement = Pattern.ensure_pattern(pattern)
36
38
  duration = Pattern.to_rational(cycles)
37
39
  return assign(slot_id, replacement) if duration <= 0
@@ -48,7 +50,7 @@ module Cyclotone
48
50
  end.then { |events| Pattern.sort_events(events) }
49
51
  end
50
52
 
51
- assign(slot_id, swapped)
53
+ assign_transition(slot_id, swapped, replacement: replacement, finish_cycle: start_cycle + duration)
52
54
  end
53
55
 
54
56
  def interpolate(id, pattern)
@@ -57,20 +59,19 @@ module Cyclotone
57
59
 
58
60
  def interpolate_in(id, cycles, pattern)
59
61
  slot_id = normalize_slot_reference(id)
60
- current = @slots[slot_id] || Pattern.silence
62
+ current = pattern_for_transition(slot_id)
61
63
  replacement = Pattern.ensure_pattern(pattern)
62
64
  duration = Pattern.to_rational(cycles)
63
65
  return assign(slot_id, replacement) if duration <= 0
64
66
 
65
67
  start_cycle = transition_start_cycle
66
68
  morphed = Pattern.new do |span|
67
- anchor_times = Pattern.stack([current, replacement]).query_span(span).map do |event|
68
- event.onset || event.part.start
69
- end.uniq.sort
69
+ source_events = current.query_span(span)
70
+ target_events = replacement.query_span(span)
70
71
 
71
- anchor_times.filter_map do |time|
72
- source_event = current.query_event_at(time)
73
- target_event = replacement.query_event_at(time)
72
+ transition_anchor_times(source_events, target_events).filter_map do |time|
73
+ source_event = event_at_time(source_events, time)
74
+ target_event = event_at_time(target_events, time)
74
75
  base_event = target_event || source_event
75
76
  next unless base_event
76
77
 
@@ -79,7 +80,7 @@ module Cyclotone
79
80
  end.then { |events| Pattern.sort_events(events) }
80
81
  end
81
82
 
82
- assign(slot_id, morphed)
83
+ assign_transition(slot_id, morphed, replacement: replacement, finish_cycle: start_cycle + duration)
83
84
  end
84
85
 
85
86
  def jump(id, pattern)
@@ -88,14 +89,14 @@ module Cyclotone
88
89
 
89
90
  def jump_in(id, cycles, pattern)
90
91
  slot_id = normalize_slot_reference(id)
91
- current = @slots[slot_id] || Pattern.silence
92
+ current = pattern_for_transition(slot_id)
92
93
  replacement = Pattern.ensure_pattern(pattern)
93
94
  switch_cycle = transition_start_cycle + Pattern.to_rational(cycles)
94
95
  return assign(slot_id, replacement) if switch_cycle <= transition_start_cycle
95
96
 
96
97
  delayed = Pattern.new { |span| split_query(span, switch_cycle, current, replacement) }
97
98
 
98
- assign(slot_id, delayed)
99
+ assign_transition(slot_id, delayed, replacement: replacement, finish_cycle: switch_cycle)
99
100
  end
100
101
 
101
102
  def anticipate(id, pattern)
@@ -107,8 +108,14 @@ module Cyclotone
107
108
  duration = Pattern.to_rational(cycles)
108
109
  return self if duration <= 0
109
110
 
110
- @slots.keys.each do |slot_id|
111
- assign(slot_id, apply_gain_envelope(@slots.fetch(slot_id), start_cycle: start_cycle, duration: duration, direction: :in))
111
+ @slots.each_key do |slot_id|
112
+ replacement = @slots.fetch(slot_id)
113
+ assign_transition(
114
+ slot_id,
115
+ apply_gain_envelope(replacement, start_cycle: start_cycle, duration: duration, direction: :in),
116
+ replacement: replacement,
117
+ finish_cycle: start_cycle + duration
118
+ )
112
119
  end
113
120
 
114
121
  self
@@ -119,8 +126,13 @@ module Cyclotone
119
126
  duration = Pattern.to_rational(cycles)
120
127
  return self if duration <= 0
121
128
 
122
- @slots.keys.each do |slot_id|
123
- assign(slot_id, apply_gain_envelope(@slots.fetch(slot_id), start_cycle: start_cycle, duration: duration, direction: :out))
129
+ @slots.each_key do |slot_id|
130
+ assign_transition(
131
+ slot_id,
132
+ apply_gain_envelope(@slots.fetch(slot_id), start_cycle: start_cycle, duration: duration, direction: :out),
133
+ replacement: Pattern.silence,
134
+ finish_cycle: start_cycle + duration
135
+ )
124
136
  end
125
137
 
126
138
  self
@@ -129,23 +141,13 @@ module Cyclotone
129
141
  private
130
142
 
131
143
  def apply_gain_envelope(pattern, start_cycle:, duration:, direction:)
132
- Pattern.ensure_pattern(pattern).fmap do |value|
133
- next value unless value.is_a?(Hash)
134
-
135
- factor = lambda do |time|
136
- progress = ((time - start_cycle.to_f) / duration.to_f).clamp(0.0, 1.0)
137
- direction == :in ? progress : 1.0 - progress
138
- end
139
-
140
- value.merge(gain_envelope: factor)
141
- end.map_events do |event|
142
- next event unless event.value.is_a?(Hash)
143
-
144
+ Pattern.ensure_pattern(pattern).map_events do |event|
144
145
  time = event.onset || event.part.start
145
- value = event.value.dup
146
- envelope = value.delete(:gain_envelope)
147
- current_gain = value[:gain] || 1.0
148
- value[:gain] = current_gain * envelope.call(time)
146
+ progress = transition_progress(time, start_cycle, duration)
147
+ factor = direction == :in ? progress : 1.0 - progress
148
+ value = event.value.is_a?(Hash) ? event.value.dup : { value: event.value }
149
+ current_gain = value.fetch(:gain, 1.0) || 1.0
150
+ value[:gain] = current_gain * factor
149
151
  event.with_value(value)
150
152
  end
151
153
  end
@@ -170,9 +172,17 @@ module Cyclotone
170
172
  ((time.to_f - start_cycle.to_f) / duration.to_f).clamp(0.0, 1.0)
171
173
  end
172
174
 
173
- def clutch_source(slot_id, time, value, start_cycle, duration)
175
+ def transition_anchor_times(source_events, target_events)
176
+ (source_events + target_events).map { |event| event.onset || event.part.start }.uniq.sort
177
+ end
178
+
179
+ def event_at_time(events, time)
180
+ events.find { |event| event.covers_time?(time) }
181
+ end
182
+
183
+ def clutch_source(slot_id, time, _value, start_cycle, duration)
174
184
  progress = transition_progress(time, start_cycle, duration)
175
- chosen = Support::Deterministic.float(:clutch, slot_id, time, value) < progress
185
+ chosen = Support::Deterministic.float(:clutch, slot_id, time) < progress
176
186
 
177
187
  chosen ? :replacement : :current
178
188
  end
@@ -191,13 +201,15 @@ module Cyclotone
191
201
  end
192
202
 
193
203
  def interpolate_hash(source, target, progress)
194
- (source.keys | target.keys).each_with_object({}) do |key, result|
195
- result[key] =
204
+ (source.keys | target.keys).to_h do |key|
205
+ value =
196
206
  if source.key?(key) && target.key?(key)
197
207
  interpolate_value(source[key], target[key], progress)
198
208
  else
199
209
  progress < 0.5 ? source.fetch(key, target[key]) : target.fetch(key, source[key])
200
210
  end
211
+
212
+ [key, value]
201
213
  end
202
214
  end
203
215
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cyclotone
4
- VERSION = "0.1.0"
4
+ VERSION = "1.0.0"
5
5
  end
data/lib/cyclotone.rb CHANGED
@@ -20,6 +20,7 @@ require_relative "cyclotone/controls"
20
20
  require_relative "cyclotone/oscillators"
21
21
  require_relative "cyclotone/harmony"
22
22
  require_relative "cyclotone/state"
23
+ require_relative "cyclotone/backends/null_backend"
23
24
  require_relative "cyclotone/backends/osc_backend"
24
25
  require_relative "cyclotone/backends/midi_backend"
25
26
  require_relative "cyclotone/backends/midi_file_backend"