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.
@@ -4,43 +4,51 @@ module Cyclotone
4
4
  module Oscillators
5
5
  module_function
6
6
 
7
- def sine
8
- Pattern.continuous { |time| (Math.sin(phase(time)) + 1.0) / 2.0 }
7
+ def sine(freq: 1, phase: 0, bipolar: false)
8
+ oscillator(freq: freq, phase_offset: phase, bipolar: bipolar) do |time|
9
+ (Math.sin(phase(time)) + 1.0) / 2.0
10
+ end
9
11
  end
10
12
 
11
- def cosine
12
- Pattern.continuous { |time| (Math.cos(phase(time)) + 1.0) / 2.0 }
13
+ def cosine(freq: 1, phase: 0, bipolar: false)
14
+ oscillator(freq: freq, phase_offset: phase, bipolar: bipolar) do |time|
15
+ (Math.cos(phase(time)) + 1.0) / 2.0
16
+ end
13
17
  end
14
18
 
15
- def tri
16
- Pattern.continuous do |time|
19
+ def tri(freq: 1, phase: 0, bipolar: false)
20
+ oscillator(freq: freq, phase_offset: phase, bipolar: bipolar) do |time|
17
21
  position = cycle_position(time)
18
22
  position < 0.5 ? position * 2.0 : (1.0 - position) * 2.0
19
23
  end
20
24
  end
21
25
 
22
- def saw
23
- Pattern.continuous { |time| cycle_position(time) }
26
+ def saw(freq: 1, phase: 0, bipolar: false)
27
+ oscillator(freq: freq, phase_offset: phase, bipolar: bipolar) { |time| cycle_position(time) }
24
28
  end
25
29
 
26
- def isaw
27
- Pattern.continuous { |time| 1.0 - cycle_position(time) }
30
+ def isaw(freq: 1, phase: 0, bipolar: false)
31
+ oscillator(freq: freq, phase_offset: phase, bipolar: bipolar) { |time| 1.0 - cycle_position(time) }
28
32
  end
29
33
 
30
- def square
31
- Pattern.continuous { |time| cycle_position(time) < 0.5 ? 0.0 : 1.0 }
34
+ def square(freq: 1, phase: 0, bipolar: false)
35
+ oscillator(freq: freq, phase_offset: phase, bipolar: bipolar) { |time| cycle_position(time) < 0.5 ? 0.0 : 1.0 }
32
36
  end
33
37
 
34
- def rand
38
+ def rand(steps: 128)
39
+ normalized_steps = positive_integer(steps, "rand steps")
40
+
35
41
  Pattern.continuous do |time|
36
42
  cycle = time.floor
37
- step = ((cycle_position(time) * 128).floor).to_i
43
+ step = (cycle_position(time) * normalized_steps).floor.to_i
38
44
  Support::Deterministic.float(:rand, cycle, step)
39
45
  end
40
46
  end
41
47
 
42
48
  def irand(maximum)
43
- rand.fmap { |value| (value * maximum.to_i).floor }
49
+ normalized_maximum = positive_integer(maximum, "irand maximum")
50
+
51
+ rand.fmap { |value| (value * normalized_maximum).floor }
44
52
  end
45
53
 
46
54
  def perlin
@@ -56,18 +64,64 @@ module Cyclotone
56
64
  end
57
65
  end
58
66
 
59
- def range(low, high, pattern)
67
+ def range(low, high, pattern, mode: :raw)
60
68
  Pattern.ensure_pattern(pattern).fmap do |value|
61
- low.to_f + ((high.to_f - low.to_f) * value.to_f)
69
+ normalized_value = normalize_range_value(value.to_f, mode)
70
+ low.to_f + ((high.to_f - low.to_f) * normalized_value)
71
+ end
72
+ end
73
+
74
+ def bipolar(pattern)
75
+ Pattern.ensure_pattern(pattern).fmap { |value| (value.to_f * 2.0) - 1.0 }
76
+ end
77
+
78
+ def noise(steps: 128)
79
+ rand(steps: steps)
80
+ end
81
+
82
+ def sample_and_hold(pattern, steps: 8)
83
+ normalized_steps = positive_integer(steps, "sample_and_hold steps")
84
+ source = Pattern.ensure_pattern(pattern)
85
+
86
+ Pattern.continuous do |time|
87
+ sampled_time = Rational((time * normalized_steps).floor, normalized_steps)
88
+ source.query_point(sampled_time)
89
+ end
90
+ end
91
+
92
+ def brownian(step: 0.1)
93
+ normalized_step = step.to_f
94
+ raise ArgumentError, "brownian step must be positive" unless normalized_step.positive?
95
+
96
+ cache = { -1 => 0.5 }
97
+ highest_cycle = -1
98
+
99
+ Pattern.continuous do |time|
100
+ cycle = time.floor
101
+
102
+ while highest_cycle < cycle
103
+ next_cycle = highest_cycle + 1
104
+ cache[next_cycle] = brownian_step(cache.fetch(highest_cycle), next_cycle, normalized_step)
105
+ highest_cycle = next_cycle
106
+ end
107
+
108
+ cache.fetch(cycle, 0.5)
62
109
  end
63
110
  end
64
111
 
65
- def smooth(pattern)
112
+ def smooth(pattern, interpolator: nil, &block)
66
113
  source = Pattern.ensure_pattern(pattern)
67
114
  return source if source.continuous?
68
115
 
116
+ interpolation = block || interpolator
117
+ cache = {}
69
118
  Pattern.continuous do |time|
70
- interpolate(source, Pattern.to_rational(time))
119
+ rational_time = Pattern.to_rational(time)
120
+ cache.fetch(rational_time) do
121
+ cache[rational_time] = interpolate(source, rational_time, interpolation)
122
+ cache.shift if cache.length > 256
123
+ cache[rational_time]
124
+ end
71
125
  end
72
126
  end
73
127
 
@@ -81,7 +135,51 @@ module Cyclotone
81
135
  end
82
136
  private_class_method :phase
83
137
 
84
- def interpolate(source, time)
138
+ def oscillator(freq:, phase_offset:, bipolar:)
139
+ normalized_freq = Pattern.to_rational(freq)
140
+ raise ArgumentError, "oscillator frequency must be positive" unless normalized_freq.positive?
141
+
142
+ offset = Pattern.to_rational(phase_offset)
143
+ Pattern.continuous do |time|
144
+ value = yield((time * normalized_freq) + offset)
145
+ bipolar ? (value * 2.0) - 1.0 : value
146
+ end
147
+ end
148
+ private_class_method :oscillator
149
+
150
+ def normalize_range_value(value, mode)
151
+ case mode.to_sym
152
+ when :raw
153
+ value
154
+ when :clamp
155
+ value.clamp(0.0, 1.0)
156
+ when :wrap
157
+ value % 1.0
158
+ when :fold
159
+ folded = value % 2.0
160
+ folded > 1.0 ? 2.0 - folded : folded
161
+ else
162
+ raise ArgumentError, "unknown range mode #{mode}"
163
+ end
164
+ end
165
+ private_class_method :normalize_range_value
166
+
167
+ def positive_integer(value, label)
168
+ normalized = Integer(value)
169
+ raise ArgumentError, "#{label} must be positive" unless normalized.positive?
170
+
171
+ normalized
172
+ rescue ArgumentError, TypeError => error
173
+ raise ArgumentError, "invalid #{label}: #{error.message}"
174
+ end
175
+
176
+ def brownian_step(value, cycle, step)
177
+ delta = (Support::Deterministic.float(:brownian, cycle) * 2.0) - 1.0
178
+ (value + (delta * step)).clamp(0.0, 1.0)
179
+ end
180
+ private_class_method :brownian_step
181
+
182
+ def interpolate(source, time, interpolation)
85
183
  anchors = anchors_for(source, time)
86
184
  return source.query_point(time) if anchors.empty?
87
185
 
@@ -89,8 +187,8 @@ module Cyclotone
89
187
  right = anchors.find { |anchor| anchor[:time] >= time } || anchors.last
90
188
  return left[:value] if left[:time] == right[:time]
91
189
 
92
- amount = (time - left[:time]).to_f / (right[:time] - left[:time]).to_f
93
- interpolate_value(left[:value], right[:value], amount)
190
+ amount = (time - left[:time]).to_f / (right[:time] - left[:time])
191
+ interpolate_value(left[:value], right[:value], amount, interpolation)
94
192
  end
95
193
  private_class_method :interpolate
96
194
 
@@ -105,25 +203,29 @@ module Cyclotone
105
203
  end
106
204
  private_class_method :anchors_for
107
205
 
108
- def interpolate_value(left, right, amount)
206
+ def interpolate_value(left, right, amount, interpolation = nil)
109
207
  if left.is_a?(Numeric) && right.is_a?(Numeric)
110
208
  left.to_f + ((right.to_f - left.to_f) * amount)
111
209
  elsif left.is_a?(Hash) && right.is_a?(Hash)
112
- interpolate_hash(left, right, amount)
210
+ interpolate_hash(left, right, amount, interpolation)
211
+ elsif interpolation
212
+ interpolation.call(left, right, amount)
113
213
  else
114
214
  amount >= 0.5 ? right : left
115
215
  end
116
216
  end
117
217
  private_class_method :interpolate_value
118
218
 
119
- def interpolate_hash(left, right, amount)
120
- (left.keys | right.keys).each_with_object({}) do |key, result|
121
- result[key] =
219
+ def interpolate_hash(left, right, amount, interpolation)
220
+ (left.keys | right.keys).to_h do |key|
221
+ value =
122
222
  if left.key?(key) && right.key?(key)
123
- interpolate_value(left[key], right[key], amount)
223
+ interpolate_value(left[key], right[key], amount, interpolation)
124
224
  else
125
225
  amount >= 0.5 ? right.fetch(key, left[key]) : left.fetch(key, right[key])
126
226
  end
227
+
228
+ [key, value]
127
229
  end
128
230
  end
129
231
  private_class_method :interpolate_hash
@@ -10,6 +10,9 @@ module Cyclotone
10
10
  include Transforms::Sample
11
11
 
12
12
  SAMPLE_EPSILON = Rational(1, 1024)
13
+ CACHE_LIMIT = 128
14
+ CACHE_MUTEX = Mutex.new
15
+ COMPILER_MUTEX = Mutex.new
13
16
 
14
17
  attr_reader :query
15
18
 
@@ -26,12 +29,17 @@ module Cyclotone
26
29
  end
27
30
 
28
31
  def query_span(span)
29
- cycle_spans = span.cycle_spans
30
- cycle_spans = [span] if cycle_spans.empty? && continuous?
32
+ span = self.class.coerce_span(span)
33
+ emitted_cycle_span = false
34
+ events = []
31
35
 
32
- cycle_spans.flat_map { |cycle_span| query.call(cycle_span) }.then do |events|
33
- self.class.sort_events(events)
36
+ span.each_cycle_span do |cycle_span|
37
+ emitted_cycle_span = true
38
+ events.concat(query.call(cycle_span))
34
39
  end
40
+
41
+ events.concat(query.call(span)) if !emitted_cycle_span && continuous?
42
+ self.class.sort_events(events)
35
43
  end
36
44
 
37
45
  def query_cycle(cycle_number)
@@ -39,13 +47,23 @@ module Cyclotone
39
47
  end
40
48
 
41
49
  def query_event_at(time)
42
- query_span(TimeSpan.new(time, time + SAMPLE_EPSILON)).find { |event| event.covers_time?(time) }
50
+ sample_time = self.class.to_rational(time)
51
+ query_span(TimeSpan.new(sample_time, sample_time + self.class.sample_epsilon)).find do |event|
52
+ event.covers_time?(sample_time)
53
+ end
43
54
  end
44
55
 
45
56
  def query_point(time)
46
57
  query_event_at(time)&.value
47
58
  end
48
59
 
60
+ def query_points(time)
61
+ sample_time = self.class.to_rational(time)
62
+ query_span(TimeSpan.new(sample_time, sample_time + self.class.sample_epsilon)).select do |event|
63
+ event.covers_time?(sample_time)
64
+ end.map(&:value)
65
+ end
66
+
49
67
  def fmap(&transform)
50
68
  raise ArgumentError, "fmap requires a block" unless transform
51
69
 
@@ -94,12 +112,24 @@ module Cyclotone
94
112
  other_pattern = self.class.ensure_pattern(other)
95
113
 
96
114
  Pattern.new do |span|
97
- left_events = query_span(span)
115
+ left_events = query_span(span).sort_by { |event| event.active_span.start }
98
116
  right_events = other_pattern.query_span(span)
117
+ right_events_by_start = right_events.sort_by { |event| event.active_span.start }
118
+ first_candidate = 0
99
119
 
100
120
  left_events.flat_map do |left_event|
101
- right_events.filter_map do |right_event|
102
- overlap = left_event.active_span.intersection(right_event.active_span)
121
+ left_span = left_event.active_span
122
+ left_stop = left_span.stop
123
+
124
+ while first_candidate < right_events_by_start.length && right_events_by_start[first_candidate].active_span.stop <= left_span.start
125
+ first_candidate += 1
126
+ end
127
+
128
+ first_candidate.upto(right_events_by_start.length - 1).filter_map do |index|
129
+ right_event = right_events_by_start.fetch(index)
130
+ break [] if right_event.active_span.start >= left_stop
131
+
132
+ overlap = left_span.intersection(right_event.active_span)
103
133
  next unless overlap
104
134
 
105
135
  part = overlap.intersection(span)
@@ -138,14 +168,24 @@ module Cyclotone
138
168
  end
139
169
 
140
170
  def merge(other)
171
+ merge_right(other)
172
+ end
173
+
174
+ def merge_left(other)
141
175
  combine_left(other) do |left, right|
142
- if left.is_a?(Hash) && right.is_a?(Hash)
143
- left.merge(right)
144
- elsif right.nil?
145
- left
146
- else
147
- right
148
- end
176
+ merge_values(right, left)
177
+ end
178
+ end
179
+
180
+ def merge_right(other)
181
+ combine_left(other) do |left, right|
182
+ merge_values(left, right)
183
+ end
184
+ end
185
+
186
+ def merge_deep(other)
187
+ combine_left(other) do |left, right|
188
+ deep_merge_values(left, right)
149
189
  end
150
190
  end
151
191
 
@@ -180,18 +220,39 @@ module Cyclotone
180
220
 
181
221
  alias atom pure
182
222
 
223
+ def atom_at(value, at:, duration: 0)
224
+ onset_offset = to_rational(at)
225
+ event_duration = to_rational(duration)
226
+ raise ArgumentError, "atom duration must be non-negative" if event_duration.negative?
227
+
228
+ Pattern.new do |span|
229
+ cycle_start = Rational(span.cycle_number)
230
+ onset = cycle_start + onset_offset
231
+ whole = TimeSpan.new(onset, onset + event_duration)
232
+ trigger_span = TimeSpan.new(onset, onset + sample_epsilon)
233
+ part = span.intersection(trigger_span)
234
+
235
+ part ? [Event.new(whole: whole, part: part, value: value)] : []
236
+ end
237
+ end
238
+
183
239
  def silence
184
240
  Pattern.new { |_span| [] }
185
241
  end
186
242
 
187
- def continuous(&sampler)
243
+ def continuous(sample: :midpoint, &sampler)
244
+ raise ArgumentError, "continuous requires a sampler block" unless sampler
245
+
188
246
  Pattern.new(continuous: true) do |span|
189
- [Event.new(whole: nil, part: span, value: sampler.call(span.midpoint))]
247
+ [Event.new(whole: nil, part: span, value: sampler.call(sample_time(span, sample)))]
190
248
  end
191
249
  end
192
250
 
193
- def ensure_pattern(value)
194
- value.is_a?(Pattern) ? value : pure(value)
251
+ def ensure_pattern(value, strings: :literal)
252
+ return value if value.is_a?(Pattern)
253
+ return mn(value) if value.is_a?(String) && strings == :mini_notation
254
+
255
+ pure(value)
195
256
  end
196
257
 
197
258
  def to_rational(value)
@@ -199,21 +260,28 @@ module Cyclotone
199
260
  return Rational(value, 1) if value.is_a?(Integer)
200
261
 
201
262
  Rational(value.to_s)
263
+ rescue ArgumentError, TypeError => error
264
+ raise InvalidRationalError, "invalid rational value #{value.inspect}: #{error.message}"
202
265
  end
203
266
 
204
267
  def timecat(weighted_patterns)
205
- normalized = Array(weighted_patterns)
268
+ normalized = Array(weighted_patterns).map do |weight, pattern|
269
+ normalized_weight = to_rational(weight)
270
+ raise ArgumentError, "timecat weights must be positive" unless normalized_weight.positive?
271
+
272
+ [normalized_weight, pattern]
273
+ end
206
274
  raise ArgumentError, "timecat requires patterns" if normalized.empty?
207
275
 
208
- total_weight = normalized.sum { |weight, _pattern| to_rational(weight) }
276
+ total_weight = normalized.sum { |weight, _pattern| weight }
277
+ raise ArgumentError, "timecat total weight must be positive" unless total_weight.positive?
209
278
 
210
279
  Pattern.new do |span|
211
280
  cycle_start = Rational(span.cycle_number)
212
281
  cursor = cycle_start
213
282
 
214
283
  normalized.flat_map do |weight, pattern|
215
- normalized_weight = to_rational(weight)
216
- segment_length = normalized_weight / total_weight
284
+ segment_length = weight / total_weight
217
285
  segment_span = TimeSpan.new(cursor, cursor + segment_length)
218
286
  overlap = span.intersection(segment_span)
219
287
  cursor += segment_length
@@ -255,11 +323,12 @@ module Cyclotone
255
323
  end
256
324
  end
257
325
 
258
- def randcat(patterns)
326
+ def randcat(patterns, namespace: :randcat)
259
327
  normalized = Array(patterns)
328
+ raise ArgumentError, "randcat requires patterns" if normalized.empty?
260
329
 
261
330
  Pattern.new do |span|
262
- index = Support::Deterministic.int(normalized.length, :randcat, span.cycle_number)
331
+ index = Support::Deterministic.int(normalized.length, namespace, span.cycle_number)
263
332
  ensure_pattern(normalized[index]).query_span(span)
264
333
  end
265
334
  end
@@ -272,9 +341,13 @@ module Cyclotone
272
341
  fastcat([first, second])
273
342
  end
274
343
 
275
- def stack(patterns)
344
+ def stack(patterns, empty: :error)
276
345
  normalized_patterns = Array(patterns).map { |pattern| ensure_pattern(pattern) }
277
- raise ArgumentError, "stack requires at least one pattern" if normalized_patterns.empty?
346
+ if normalized_patterns.empty?
347
+ return silence if empty == :silence
348
+
349
+ raise ArgumentError, "stack requires at least one pattern"
350
+ end
278
351
 
279
352
  Pattern.new do |span|
280
353
  normalized_patterns.flat_map { |pattern| pattern.query_span(span) }.then do |events|
@@ -283,28 +356,69 @@ module Cyclotone
283
356
  end
284
357
  end
285
358
 
359
+ def stack_or_silence(patterns)
360
+ stack(patterns, empty: :silence)
361
+ end
362
+
286
363
  def overlay(first, second)
287
364
  stack([first, second])
288
365
  end
289
366
 
290
367
  def mn(string)
291
- compiler.compile(parser.parse(string))
368
+ source = string.to_s
369
+ cached_pattern(source) { compiler.compile(parser.parse(source)) }
370
+ end
371
+
372
+ def mn!(string)
373
+ mn(string)
374
+ end
375
+
376
+ def try_mn(string)
377
+ mn(string)
378
+ rescue ParseError, ArgumentError
379
+ nil
292
380
  end
293
381
 
294
382
  def parser
295
- @parser ||= MiniNotation::Parser.new
383
+ MiniNotation::Parser.new
296
384
  end
297
385
 
298
386
  def compiler
299
- @compiler ||= MiniNotation::Compiler.new
387
+ return @compiler if @compiler
388
+
389
+ COMPILER_MUTEX.synchronize do
390
+ @compiler ||= MiniNotation::Compiler.new
391
+ end
300
392
  end
301
393
 
302
394
  def sort_events(events)
303
395
  events.sort_by do |event|
304
- [event.onset || event.part.start, event.offset || event.part.stop, event.value.to_s]
396
+ [
397
+ event.onset || event.part.start,
398
+ event.offset || event.part.stop,
399
+ Support::Deterministic.canonical_key(event.value)
400
+ ]
305
401
  end
306
402
  end
307
403
 
404
+ def coerce_span(value)
405
+ return value if value.is_a?(TimeSpan)
406
+ return TimeSpan.new(value.fetch(0), value.fetch(1)) if value.respond_to?(:fetch)
407
+
408
+ raise ArgumentError, "expected TimeSpan or [start, stop], got #{value.class}"
409
+ end
410
+
411
+ def sample_epsilon
412
+ @sample_epsilon ||= SAMPLE_EPSILON
413
+ end
414
+
415
+ def sample_epsilon=(value)
416
+ normalized = to_rational(value)
417
+ raise ArgumentError, "sample epsilon must be positive" unless normalized.positive?
418
+
419
+ @sample_epsilon = normalized
420
+ end
421
+
308
422
  def map_span(span, &block)
309
423
  return nil unless span
310
424
 
@@ -323,6 +437,38 @@ module Cyclotone
323
437
  offset = to_rational(amount)
324
438
  map_event(event) { |time| time + offset }
325
439
  end
440
+
441
+ private
442
+
443
+ def sample_time(span, sample)
444
+ case sample
445
+ when :begin, :start
446
+ span.start
447
+ when :end, :stop
448
+ span.stop
449
+ when :midpoint, :center
450
+ span.midpoint
451
+ else
452
+ raise ArgumentError, "unknown continuous sample point #{sample.inspect}"
453
+ end
454
+ end
455
+
456
+ def cached_pattern(source)
457
+ CACHE_MUTEX.synchronize do
458
+ @mn_cache ||= {}
459
+ @mn_cache_order ||= []
460
+
461
+ return @mn_cache[source] if @mn_cache.key?(source)
462
+
463
+ pattern = yield
464
+ @mn_cache[source] = pattern
465
+ @mn_cache_order << source
466
+
467
+ @mn_cache.delete(@mn_cache_order.shift) while @mn_cache_order.length > CACHE_LIMIT
468
+
469
+ pattern
470
+ end
471
+ end
326
472
  end
327
473
 
328
474
  private
@@ -339,23 +485,52 @@ module Cyclotone
339
485
  end
340
486
 
341
487
  def combine_scalar(left, right, operator)
488
+ return left if right.nil?
489
+
342
490
  if left.is_a?(Hash) && right.is_a?(Hash)
343
491
  keys = left.keys | right.keys
344
- keys.each_with_object({}) do |key, result|
345
- result[key] =
346
- if left.key?(key) && right.key?(key) && left[key].respond_to?(operator)
492
+ keys.to_h do |key|
493
+ value =
494
+ if left.key?(key) && right.key?(key) && right[key].nil?
495
+ left[key]
496
+ elsif left.key?(key) && right.key?(key) && left[key].respond_to?(operator)
347
497
  left[key].public_send(operator, right[key])
348
498
  else
349
499
  right.fetch(key, left[key])
350
500
  end
501
+
502
+ [key, value]
351
503
  end
352
- elsif right.nil?
353
- left
354
504
  elsif left.respond_to?(operator)
355
505
  left.public_send(operator, right)
356
506
  else
357
507
  left
358
508
  end
359
509
  end
510
+
511
+ def merge_values(left, right)
512
+ if left.is_a?(Hash) && right.is_a?(Hash)
513
+ left.merge(right.compact)
514
+ elsif right.nil?
515
+ left
516
+ else
517
+ right
518
+ end
519
+ end
520
+
521
+ def deep_merge_values(left, right)
522
+ return left if right.nil?
523
+ return right unless left.is_a?(Hash) && right.is_a?(Hash)
524
+
525
+ right.each_with_object(left.dup) do |(key, value), merged|
526
+ next if value.nil?
527
+
528
+ merged[key] = if merged[key].is_a?(Hash) && value.is_a?(Hash)
529
+ deep_merge_values(merged[key], value)
530
+ else
531
+ value
532
+ end
533
+ end
534
+ end
360
535
  end
361
536
  end