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,6 +4,19 @@ module Cyclotone
4
4
  module MiniNotation
5
5
  module AST
6
6
  class Node
7
+ def self.deep_freeze(value)
8
+ case value
9
+ when Array
10
+ value.map { |entry| deep_freeze(entry) }.freeze
11
+ when Hash
12
+ value.each_with_object({}) do |(key, entry), frozen_hash|
13
+ frozen_hash[deep_freeze(key)] = deep_freeze(entry)
14
+ end.freeze
15
+ else
16
+ value.freeze
17
+ end
18
+ end
19
+
7
20
  def ==(other)
8
21
  other.is_a?(self.class) && to_h == other.to_h
9
22
  end
@@ -13,12 +26,17 @@ module Cyclotone
13
26
  def hash
14
27
  [self.class, to_h].hash
15
28
  end
29
+
30
+ def to_mn
31
+ AST.to_source(self)
32
+ end
16
33
  end
17
34
 
18
35
  class Atom < Node
19
36
  attr_reader :value, :sample
20
37
 
21
38
  def initialize(value:, sample: nil)
39
+ super()
22
40
  @value = value
23
41
  @sample = sample
24
42
  freeze
@@ -43,7 +61,8 @@ module Cyclotone
43
61
  attr_reader :elements
44
62
 
45
63
  def initialize(elements:)
46
- @elements = elements.freeze
64
+ super()
65
+ @elements = Node.deep_freeze(elements)
47
66
  freeze
48
67
  end
49
68
 
@@ -56,7 +75,8 @@ module Cyclotone
56
75
  attr_reader :patterns
57
76
 
58
77
  def initialize(patterns:)
59
- @patterns = patterns.freeze
78
+ super()
79
+ @patterns = Node.deep_freeze(patterns)
60
80
  freeze
61
81
  end
62
82
 
@@ -69,7 +89,8 @@ module Cyclotone
69
89
  attr_reader :patterns
70
90
 
71
91
  def initialize(patterns:)
72
- @patterns = patterns.freeze
92
+ super()
93
+ @patterns = Node.deep_freeze(patterns)
73
94
  freeze
74
95
  end
75
96
 
@@ -82,6 +103,7 @@ module Cyclotone
82
103
  attr_reader :pattern, :count
83
104
 
84
105
  def initialize(pattern:, count:)
106
+ super()
85
107
  @pattern = pattern
86
108
  @count = count.to_i
87
109
  freeze
@@ -96,6 +118,7 @@ module Cyclotone
96
118
  attr_reader :pattern, :count
97
119
 
98
120
  def initialize(pattern:, count:)
121
+ super()
99
122
  @pattern = pattern
100
123
  @count = count.to_i
101
124
  freeze
@@ -110,6 +133,7 @@ module Cyclotone
110
133
  attr_reader :pattern, :amount
111
134
 
112
135
  def initialize(pattern:, amount:)
136
+ super()
113
137
  @pattern = pattern
114
138
  @amount = amount
115
139
  freeze
@@ -124,6 +148,7 @@ module Cyclotone
124
148
  attr_reader :pattern, :amount
125
149
 
126
150
  def initialize(pattern:, amount:)
151
+ super()
127
152
  @pattern = pattern
128
153
  @amount = amount
129
154
  freeze
@@ -142,6 +167,7 @@ module Cyclotone
142
167
  attr_reader :pattern, :probability
143
168
 
144
169
  def initialize(pattern:, probability:)
170
+ super()
145
171
  @pattern = pattern
146
172
  @probability = probability
147
173
  freeze
@@ -156,7 +182,8 @@ module Cyclotone
156
182
  attr_reader :patterns
157
183
 
158
184
  def initialize(patterns:)
159
- @patterns = patterns.freeze
185
+ super()
186
+ @patterns = Node.deep_freeze(patterns)
160
187
  freeze
161
188
  end
162
189
 
@@ -169,6 +196,7 @@ module Cyclotone
169
196
  attr_reader :pattern, :pulses, :steps, :rotation
170
197
 
171
198
  def initialize(pattern:, pulses:, steps:, rotation: 0)
199
+ super()
172
200
  @pattern = pattern
173
201
  @pulses = pulses.to_i
174
202
  @steps = steps.to_i
@@ -185,7 +213,8 @@ module Cyclotone
185
213
  attr_reader :patterns, :steps
186
214
 
187
215
  def initialize(patterns:, steps: nil)
188
- @patterns = patterns.freeze
216
+ super()
217
+ @patterns = Node.deep_freeze(patterns)
189
218
  @steps = steps&.to_i
190
219
  freeze
191
220
  end
@@ -194,6 +223,57 @@ module Cyclotone
194
223
  { patterns: patterns, steps: steps }
195
224
  end
196
225
  end
226
+
227
+ module_function
228
+
229
+ def to_source(node)
230
+ case node
231
+ when Atom
232
+ node.sample ? "#{format_atom(node.value)}:#{node.sample}" : format_atom(node.value)
233
+ when Rest
234
+ "~"
235
+ when Sequence
236
+ node.elements.map { |element| to_source(element) }.join(" ")
237
+ when Stack
238
+ "[#{node.patterns.map { |pattern| to_source(pattern) }.join(", ")}]"
239
+ when Alternating
240
+ "<#{node.patterns.map { |pattern| to_source(pattern) }.join(" ")}>"
241
+ when Repeat
242
+ "#{group_if_needed(node.pattern)}*#{node.count}"
243
+ when Replicate
244
+ "#{group_if_needed(node.pattern)}!#{node.count}"
245
+ when Slow
246
+ "#{group_if_needed(node.pattern)}/#{node.amount}"
247
+ when Elongate
248
+ "#{group_if_needed(node.pattern)}@#{node.amount}"
249
+ when Degrade
250
+ "#{group_if_needed(node.pattern)}?#{node.probability}"
251
+ when Choice
252
+ node.patterns.map { |pattern| to_source(pattern) }.join(" | ")
253
+ when Euclidean
254
+ "#{group_if_needed(node.pattern)}(#{node.pulses},#{node.steps},#{node.rotation})"
255
+ when Polymetric
256
+ suffix = node.steps ? "%#{node.steps}" : ""
257
+ "{#{node.patterns.map { |pattern| to_source(pattern) }.join(", ")}}#{suffix}"
258
+ else
259
+ raise ArgumentError, "unsupported AST node #{node.class}"
260
+ end
261
+ end
262
+
263
+ def group_if_needed(node)
264
+ node.is_a?(Atom) || node.is_a?(Rest) ? to_source(node) : "[#{to_source(node)}]"
265
+ end
266
+ private_class_method :group_if_needed
267
+
268
+ def format_atom(value)
269
+ return "#{value.numerator}/#{value.denominator}" if value.is_a?(Rational)
270
+
271
+ text = value.to_s
272
+ return text if text.match?(/\A[\w.-]+\z/)
273
+
274
+ "\"#{text.gsub("\\", "\\\\\\").gsub("\"", "\\\"")}\""
275
+ end
276
+ private_class_method :format_atom
197
277
  end
198
278
  end
199
279
  end
@@ -3,6 +3,8 @@
3
3
  module Cyclotone
4
4
  module MiniNotation
5
5
  class Compiler
6
+ MAX_EXPANSION = 4096
7
+
6
8
  def compile(node)
7
9
  case node
8
10
  when AST::Atom
@@ -16,12 +18,12 @@ module Cyclotone
16
18
  when AST::Alternating
17
19
  compile_alternating(node)
18
20
  when AST::Repeat
21
+ validate_expansion_count(node.count, "repeat count")
19
22
  compile(node.pattern).fast(node.count)
20
23
  when AST::Replicate
24
+ validate_expansion_count(node.count, "replicate count")
21
25
  Pattern.timecat(Array.new(node.count) { [1, compile(node.pattern)] })
22
- when AST::Slow
23
- compile(node.pattern).slow(node.amount)
24
- when AST::Elongate
26
+ when AST::Slow, AST::Elongate
25
27
  compile(node.pattern).slow(node.amount)
26
28
  when AST::Degrade
27
29
  compile(node.pattern).degrade_by(node.probability)
@@ -60,6 +62,7 @@ module Cyclotone
60
62
 
61
63
  def compile_alternating(node)
62
64
  patterns = node.patterns.map { |pattern| compile(pattern) }
65
+ raise ArgumentError, "alternating requires patterns" if patterns.empty?
63
66
 
64
67
  Pattern.new do |span|
65
68
  cycle = span.cycle_number
@@ -77,21 +80,21 @@ module Cyclotone
77
80
  end
78
81
 
79
82
  def compile_polymetric(node)
80
- base_steps = (node.steps || step_count(node.patterns.first)).to_i
81
- base_steps = 1 if base_steps <= 0
83
+ raise ArgumentError, "polymetric requires patterns" if node.patterns.empty?
84
+
85
+ base_steps = Pattern.to_rational(node.steps || step_count(node.patterns.first))
86
+ raise ArgumentError, "polymetric steps must be positive" unless base_steps.positive?
82
87
 
83
88
  Pattern.stack(
84
89
  node.patterns.map do |pattern|
85
- pattern_steps = [step_count(pattern), 1].max
86
- compile(pattern).slow(Rational(pattern_steps, base_steps))
90
+ pattern_steps = [Pattern.to_rational(step_count(pattern)), Rational(1)].max
91
+ compile(pattern).slow(pattern_steps / base_steps)
87
92
  end
88
93
  )
89
94
  end
90
95
 
91
96
  def step_count(node)
92
97
  case node
93
- when AST::Atom, AST::Rest
94
- 1
95
98
  when AST::Sequence
96
99
  node.elements.sum { |element| step_count(element) }
97
100
  when AST::Stack, AST::Alternating, AST::Choice
@@ -101,7 +104,7 @@ module Cyclotone
101
104
  when AST::Slow, AST::Degrade
102
105
  step_count(node.pattern)
103
106
  when AST::Elongate
104
- step_count(node.pattern) * node.amount.to_i
107
+ Pattern.to_rational(step_count(node.pattern)) * Pattern.to_rational(node.amount)
105
108
  when AST::Euclidean
106
109
  [node.steps, 1].max * step_count(node.pattern)
107
110
  when AST::Polymetric
@@ -110,6 +113,11 @@ module Cyclotone
110
113
  1
111
114
  end
112
115
  end
116
+
117
+ def validate_expansion_count(count, label)
118
+ raise ArgumentError, "#{label} must be positive" unless count.positive?
119
+ raise ArgumentError, "#{label} must be <= #{MAX_EXPANSION}" if count > MAX_EXPANSION
120
+ end
113
121
  end
114
122
  end
115
123
  end
@@ -27,11 +27,12 @@ module Cyclotone
27
27
  }.freeze
28
28
 
29
29
  def parse(input)
30
- @tokens = tokenize(input.to_s)
30
+ @source = input.to_s
31
+ @tokens = tokenize(@source)
31
32
  @index = 0
32
33
 
33
34
  skip_spaces
34
- raise ParseError.new("input is empty", line: 1, column: 1) if current.type == :eof
35
+ raise ParseError.new("input is empty", line: 1, column: 1, source: @source) if current.type == :eof
35
36
 
36
37
  ast = parse_stack(terminators: [:eof])
37
38
  skip_spaces
@@ -86,6 +87,12 @@ module Cyclotone
86
87
  next
87
88
  end
88
89
 
90
+ if char.match?(/[0-9]/) || (char == "-" && input[index + 1]&.match?(/[0-9]/))
91
+ token, index, column = tokenize_number(input, index, line, column)
92
+ tokens << token
93
+ next
94
+ end
95
+
89
96
  if SINGLE_CHAR_TOKENS.key?(char)
90
97
  tokens << Token.new(type: SINGLE_CHAR_TOKENS[char], value: char, line: line, column: column)
91
98
  index += 1
@@ -93,15 +100,9 @@ module Cyclotone
93
100
  next
94
101
  end
95
102
 
96
- if char.match?(/[0-9]/)
97
- start_index = index
98
- start_column = column
99
- while index < input.length && input[index].match?(/[0-9.]/)
100
- index += 1
101
- column += 1
102
- end
103
-
104
- tokens << Token.new(type: :number, value: input[start_index...index], line: line, column: start_column)
103
+ if %w[" '].include?(char)
104
+ token, index, column = tokenize_quoted(input, index, line, column)
105
+ tokens << token
105
106
  next
106
107
  end
107
108
 
@@ -130,6 +131,8 @@ module Cyclotone
130
131
 
131
132
  while accept(:comma)
132
133
  skip_spaces
134
+ raise parse_error("empty stack branch") if terminators.include?(current.type) || current.type == :comma
135
+
133
136
  patterns << parse_choice(terminators: terminators + [:comma])
134
137
  skip_spaces
135
138
  end
@@ -145,6 +148,8 @@ module Cyclotone
145
148
 
146
149
  while accept(:pipe)
147
150
  skip_spaces
151
+ raise parse_error("empty choice branch") if terminators.include?(current.type) || current.type == :pipe
152
+
148
153
  patterns << parse_sequence(terminators: terminators + [:pipe])
149
154
  skip_spaces
150
155
  end
@@ -199,26 +204,30 @@ module Cyclotone
199
204
  node = case current.type
200
205
  when :star
201
206
  advance
202
- AST::Repeat.new(pattern: node, count: parse_integer)
207
+ AST::Repeat.new(pattern: node, count: parse_positive_integer("repeat count"))
203
208
  when :bang
204
209
  advance
205
- AST::Replicate.new(pattern: node, count: parse_integer)
210
+ AST::Replicate.new(pattern: node, count: parse_positive_integer("replicate count"))
206
211
  when :slash
207
212
  advance
208
- AST::Slow.new(pattern: node, amount: parse_number)
213
+ AST::Slow.new(pattern: node, amount: parse_positive_number("slow amount"))
209
214
  when :at
210
215
  advance
211
- AST::Elongate.new(pattern: node, amount: parse_number)
216
+ AST::Elongate.new(pattern: node, amount: parse_positive_number("elongate amount"))
212
217
  when :question
213
218
  advance
214
- probability = current.type == :number ? parse_number : 0.5
219
+ probability = if current.type == :number
220
+ parse_probability
221
+ elsif implicit_probability_token?(current.type)
222
+ 0.5
223
+ else
224
+ raise parse_error("probability must be between 0 and 1")
225
+ end
215
226
  AST::Degrade.new(pattern: node, probability: probability)
216
227
  when :colon
217
228
  advance
218
- sample = parse_integer
219
- unless node.is_a?(AST::Atom)
220
- raise parse_error("sample suffix can only be applied to atoms")
221
- end
229
+ sample = parse_non_negative_integer("sample number")
230
+ raise parse_error("sample suffix can only be applied to atoms") unless node.is_a?(AST::Atom)
222
231
 
223
232
  node.with_sample(sample)
224
233
  when :lparen
@@ -240,8 +249,7 @@ module Cyclotone
240
249
  AST::Atom.new(value: token.value)
241
250
  when :number
242
251
  advance
243
- numeric_value = token.value.include?(".") ? token.value.to_f : token.value.to_i
244
- AST::Atom.new(value: numeric_value)
252
+ AST::Atom.new(value: number_value(token.value))
245
253
  when :tilde
246
254
  advance
247
255
  AST::Rest.new
@@ -264,14 +272,12 @@ module Cyclotone
264
272
 
265
273
  def parse_euclidean(node)
266
274
  expect(:lparen)
267
- pulses = parse_integer
275
+ pulses = parse_non_negative_integer("euclidean pulses")
268
276
  expect(:comma)
269
- steps = parse_integer
277
+ steps = parse_positive_integer("euclidean steps")
270
278
  rotation = 0
271
279
 
272
- if accept(:comma)
273
- rotation = parse_integer
274
- end
280
+ rotation = parse_integer("euclidean rotation") if accept(:comma)
275
281
 
276
282
  expect(:rparen)
277
283
  AST::Euclidean.new(pattern: node, pulses: pulses, steps: steps, rotation: rotation)
@@ -279,14 +285,18 @@ module Cyclotone
279
285
 
280
286
  def parse_polymetric
281
287
  expect(:lbrace)
282
- patterns = [parse_sequence(terminators: [:comma, :rbrace])]
288
+ raise parse_error("empty polymetric branch") if current.type == :rbrace
289
+
290
+ patterns = [parse_sequence(terminators: %i[comma rbrace])]
283
291
 
284
292
  while accept(:comma)
285
- patterns << parse_sequence(terminators: [:comma, :rbrace])
293
+ raise parse_error("empty polymetric branch") if %i[comma rbrace].include?(current.type)
294
+
295
+ patterns << parse_sequence(terminators: %i[comma rbrace])
286
296
  end
287
297
 
288
298
  expect(:rbrace)
289
- steps = accept(:percent) ? parse_integer : nil
299
+ steps = accept(:percent) ? parse_positive_integer("polymetric steps") : nil
290
300
 
291
301
  AST::Polymetric.new(patterns: patterns, steps: steps)
292
302
  end
@@ -294,11 +304,42 @@ module Cyclotone
294
304
  def parse_number
295
305
  token = expect(:number)
296
306
 
297
- token.value.include?(".") ? token.value.to_f : token.value.to_i
307
+ number_value(token.value)
298
308
  end
299
309
 
300
- def parse_integer
301
- expect(:number).value.to_i
310
+ def parse_integer(label = "integer")
311
+ token = expect(:number)
312
+ raise parse_error("#{label} must be an integer") if token.value.include?(".") || token.value.include?("/")
313
+
314
+ token.value.to_i
315
+ end
316
+
317
+ def parse_positive_number(label)
318
+ value = parse_number
319
+ raise parse_error("#{label} must be positive") unless value.positive?
320
+
321
+ value
322
+ end
323
+
324
+ def parse_probability
325
+ value = parse_number
326
+ raise parse_error("probability must be between 0 and 1") unless value.between?(0, 1)
327
+
328
+ value
329
+ end
330
+
331
+ def parse_positive_integer(label)
332
+ value = parse_integer(label)
333
+ raise parse_error("#{label} must be positive") unless value.positive?
334
+
335
+ value
336
+ end
337
+
338
+ def parse_non_negative_integer(label)
339
+ value = parse_integer(label)
340
+ raise parse_error("#{label} must be non-negative") if value.negative?
341
+
342
+ value
302
343
  end
303
344
 
304
345
  def build_group(elements)
@@ -343,7 +384,100 @@ module Cyclotone
343
384
  end
344
385
 
345
386
  def parse_error(message)
346
- ParseError.new(message, line: current.line, column: current.column)
387
+ ParseError.new(message, line: current.line, column: current.column, source: @source)
388
+ end
389
+
390
+ def implicit_probability_token?(type)
391
+ %i[
392
+ space eof rbracket rangle rbrace comma pipe dot star bang slash at question colon lparen
393
+ ].include?(type)
394
+ end
395
+
396
+ def tokenize_number(input, index, line, column)
397
+ start_index = index
398
+ start_column = column
399
+
400
+ if input[index] == "-"
401
+ index += 1
402
+ column += 1
403
+ end
404
+
405
+ while index < input.length && input[index].match?(/[0-9]/)
406
+ index += 1
407
+ column += 1
408
+ end
409
+
410
+ if input[index] == "."
411
+ unless input[index + 1]&.match?(/[0-9]/)
412
+ raise ParseError.new("invalid number literal", line: line, column: column, source: @source)
413
+ end
414
+
415
+ index += 1
416
+ column += 1
417
+
418
+ while index < input.length && input[index].match?(/[0-9]/)
419
+ index += 1
420
+ column += 1
421
+ end
422
+ end
423
+
424
+ raise ParseError.new("invalid number literal", line: line, column: column, source: @source) if input[index] == "."
425
+
426
+ if input[index] == "/" && input[index + 1]&.match?(/[0-9]/)
427
+ index += 1
428
+ column += 1
429
+ denominator_start = index
430
+
431
+ while index < input.length && input[index].match?(/[0-9]/)
432
+ index += 1
433
+ column += 1
434
+ end
435
+
436
+ if input[denominator_start...index].to_i.zero?
437
+ raise ParseError.new("rational denominator must be positive", line: line, column: denominator_start + 1, source: @source)
438
+ end
439
+ end
440
+
441
+ [Token.new(type: :number, value: input[start_index...index], line: line, column: start_column), index, column]
442
+ end
443
+
444
+ def number_value(value)
445
+ return Rational(value) if value.include?("/")
446
+ return value.to_f if value.include?(".")
447
+
448
+ value.to_i
449
+ end
450
+
451
+ def tokenize_quoted(input, index, line, column)
452
+ quote = input[index]
453
+ start_column = column
454
+ index += 1
455
+ column += 1
456
+ value = +""
457
+
458
+ while index < input.length
459
+ char = input[index]
460
+
461
+ if char == quote
462
+ index += 1
463
+ column += 1
464
+ return [Token.new(type: :word, value: value, line: line, column: start_column), index, column]
465
+ end
466
+
467
+ if char == "\\"
468
+ index += 1
469
+ column += 1
470
+ raise ParseError.new("unterminated escape sequence", line: line, column: column, source: @source) if index >= input.length
471
+
472
+ char = input[index]
473
+ end
474
+
475
+ value << char
476
+ index += 1
477
+ column += 1
478
+ end
479
+
480
+ raise ParseError.new("unterminated quoted atom", line: line, column: start_column, source: @source)
347
481
  end
348
482
  end
349
483
  end